Compare commits

..
Author SHA1 Message Date
shufeng2012 121e155248 Merge branch 'main' of ssh://git.cancerai.top:2000/qing/blackbird into dev_shufeng2012 2026-08-18 15:39:16 +08:00
qing d513c5f892 feat(aibox): interactive model profile manager (model_config.py)
Menu-driven list/add/delete/connectivity-test for config.json models
section - no hand-written JSON needed. Key convention preserved: env
var name only, never plaintext (or empty = keyless local endpoint).
Delete auto-cleans dangling fallback refs; default profile via
api.default_model; save failure reported. 17 unit tests, 441 passed.
2026-08-18 03:26:53 +00:00
qing 4cb97318b5 docs: sync aibox audit batches G/H/I in root README and aibox README
- root README: aibox section notes four audit rounds (Batch E/F/G/H/I)
- aibox README: v3.5 changelog (Batch G/H/I), test suite count 424,
  test file listing and aibox.py line count refreshed
2026-08-18 03:15:10 +00:00
qing 31b2a4d6ca docs: batch I audit fixes record in aibox audit report
Batch I (remaining-item sweep): 10 fixes landed, 16 new tests
(test_i_fixes.py), 424 passed. Q7a / M4 / Q9 / F8 kept as-is with
rationales recorded.
2026-08-18 03:09:18 +00:00
qing db54921066 fix(aibox): batch I audit fixes - prompt write cap, light-subagent cancel propagation, atomic history writes
Remaining-item sweep (audit report §12):
- N9: cap prompt file writes (identity/soul/status, 10k chars) to bound
  persistent-injection surface
- F5: run_light_subagent switches to stream_chat + parent cancel_event
  (mid-stream interrupt); ExpressEmotionTool injects parent event
- Q4: busy_timeout=10s on mark_as_triggered (multi-process DB contention)
- Q5: malformed reminder_time tasks returned as due (were silently dropped
  by SQL-level string filter, which is now Python-side authoritative check)
- Q7b: load_conversation realpath prefix check (../ traversal defense)
- Q8: atomic JSON writes (tmp+os.replace) for index/current/archive
- F6: dedicated ws command executor for /new, /reload_prompts
- F9: --once hard-exit backstop after soft timeout
- PromptManager singleton lock; limit=0 semantics (None = no LIMIT)
2026-08-18 03:09:05 +00:00
qing 9fee9a1478 docs: batch H audit fixes record in aibox audit report
Fourth round (HEAD d1f780e): 3-way parallel review + main-reviewer
verification + Linux PoC 6/6; 13 fixes landed, 26 new tests
(test_h_fixes.py), 408 passed.
2026-08-18 02:33:34 +00:00
qing d3acccd146 fix(aibox): batch H audit fixes - subagent cancel propagation, scheduler timeout, exec process-group reaping
Fourth audit round (see docs/reports/aibox_audit_20260817.md §11):
- H1: shared parent cancel event no longer cleared on subagent first think
  (N5 regression); parent-already-cancelled skips subagent start
- M1: /reload_prompts moved off event loop (run_in_executor, N3 sibling)
- M2: skill --help scan prewarmed before serving connections
- M3: scheduler task-level timeout (scheduler.task_timeout, default 600s)
- M4: get_recent_memories(None) capped instead of full-table load
- M5: memo delete now physically removes rows (was complete-only)
- M6: PromptManager.reload builds new list then atomically swaps
- L1-L12: read_file arg coercion, proxy save failure reported, SSE
  utf-8 tolerance, persona realpath check, truncation char boundary,
  compressor div0, skill path quoting, ask_user required, POSIX
  process-group kill (pgid captured at Popen), web scheme whitelist,
  registry redact substring match, Bearer redaction in failover logs
2026-08-18 02:33:17 +00:00
qing d1f780e381 feat(aibox): verify SOCKS5 connectivity before saving proxy config
- set_proxy 先做完整 SOCKS5 握手 + generate_204 探测(trust_env=False,超时 4s/6s),验证失败不落盘并按类型归因提示(auth/timeout/connect/missing_dependency/probe_failed)

- 异常类型优先分类,消息关键词兜底:Windows 本地化错误消息不影响归因

- 新增 skip_check 逃生口(代理未启动的预配置场景);禁用代理不验证;password_env 指向空环境变量时提前精准提示

- 测试:+9 用例(mock 网络 + 真实异常类型),全量 383 passed 零回归
2026-08-18 07:44:17 +08:00
qing acb6ddbb4c docs: batch G audit fixes record in aibox audit report
Add round-3 audit (section 9): legacy item re-verification (17 items), v3.4 multi-model profile + L4 failover review (P1-P7), architecture compliance matrix. Add batch G fix record (section 10): 17 fixes across 5 waves, 20 new regression tests, full suite 375 passed.
2026-08-18 07:21:34 +08:00
qing cccc946660 fix(aibox): batch G audit fixes - llm hardening, thread lifecycle, cancel propagation, db transactions
P-series (llm_client): truncate non-transient HTTP error body to 200 chars, cap Retry-After at 3600s, validate fallback profiles (missing model skipped with warning), dedupe fallback list, remove dead _cancelled flag, document FailoverClient concurrency contract.

N-series (aibox/registry): batch-level drain thread with try/finally reclamation (N2), subagent cancel propagation via shared parent event + watcher thread (N5), /new reset moved off event loop (N3), streaming forward bounded by TOOL_MAX_RESULT_LENGTH (N8), persona path boundary check (N12), broadcast except narrowed + discard (N13), per-connection task set registration (N14).

db/history: FTS5 quote escaping (N7), mark_as_triggered single BEGIN IMMEDIATE transaction (N10), LIMIT parameterization (L1), archive filename collision suffix (N16).

Tests: +20 cases in test_g_fixes.py, full suite 375 passed.
2026-08-18 07:21:29 +08:00
qing aa15841d73 docs: sync aibox multi-model profile (fallback failover) in root README and ARCHITECTURE 2026-08-18 00:53:51 +08:00
qing 0246943165 feat(aibox): failover observability, timeout budget and Retry-After (L4c)
Observability: FailoverClient.get_stats() exposes aggregate counters
(requests / failovers / cooldown_skips / exhausted, snapshot copy);
new on_failover(from_name, to_name, reason) callback fires on every
failover decision — to_name=None marks chain exhaustion — for future
admin-panel/frontend wiring, wrapped so callback exceptions never
affect the request path.

Timeout budget: profile field "failover_timeout_budget" caps the total
seconds spent walking the chain (default unlimited; values <= 0 are
rejected to None with a warning). Once elapsed, remaining profiles are
skipped — prevents worst-case N x timeout stacking on longer chains.
Exhaustion is counted once per call via a single end-of-loop increment
(an initial double count at the budget branch was caught by the new
budget test and folded into the unified counter).

Retry-After: TransientLLMError now carries retry_after parsed from the
numeric Retry-After response header (non-numeric values like HTTP-dates
are ignored); FailoverClient._mark_fail uses it to extend that
profile's cooldown beyond the default 60s, respecting server-side
throttling on 429s. _fail_at entries became (fail_time, cooldown)
tuples; three existing tests that poke the internal map were updated.

Config: create_llm_client passes profile.failover_timeout_budget into
the FailoverClient constructor.

Tests: new TestL4cObservabilityAndBudget (13 cases: stats on
success/exhaustion/cooldown-skip, snapshot copy, hook called with
chain-tail None, hook exception safety, budget stops chain via mocked
monotonic, invalid budget ignored, config field plumbing, default
no-budget regression, Retry-After parse/ignore/extend-cooldown). Full
suite 355 passed.
2026-08-18 00:52:39 +08:00
qing d53eba77f0 feat(aibox): stream_chat first-event failover + cancel-flag guard (L4b)
FailoverClient.stream_chat now walks the chain but only fails over
while no event has been produced yet — replay before the first
reasoning/content/tool_call event is invisible to the user. Once any
event is out, failures propagate directly (replay would duplicate
frontend output and corrupt tool-call accumulation).

DeepSeekClient.stream_chat classifies non-200 responses: 429/5xx raise
TransientLLMError (failover-eligible), auth/param 4xx keep raising
RuntimeError. Chain exhaustion re-raises the last exception so the
existing rollback/error handling in think_and_respond catches it; an
all-cooling chain raises RuntimeError.

Cancel-flag guard: LLMClient.cancel() sets _cancelled because the
RequestException raised by force-closing the connection is
indistinguishable from a real network fault. FailoverClient.cancel()
is now defined explicitly (overriding __getattr__ delegation),
broadcasts to every client in the chain and sets the wrapper flag;
stream_chat/chat_completion reset it per call and suppress failover
when it is set — otherwise interrupting would make the system hop to
another vendor and keep generating against the user's intent.

Tests: new TestL4bStreamFailover (12 cases: failover before first
event on TransientLLMError/ConnectionError, no failover after first
event, cancel never fails over on both paths, cancel broadcast, chain
exhaustion, all-cooling, cooldown skip, success isolation, transient/
auth status classification). Full suite 342 passed.
2026-08-18 00:46:23 +08:00
qing 3d7f5ca3cb feat(aibox): chat_completion cross-profile failover via fallback chain (L4a)
When a profile declares "fallback": ["kimi", "local"], create_llm_client
now returns a FailoverClient wrapper holding the ordered chain. For the
idempotent non-streaming path (chat_completion — conversation
compression, light subagents):

- Fail over on transient errors only: HTTP 429/5xx (now raised as
  TransientLLMError by DeepSeekClient) and connection errors/timeouts
  (previously swallowed into None by chat_completion's catch-all, now
  re-raised). Auth/param 4xx failures keep returning None with no
  failover — config problems stay loud, consistent with the H1/H2
  error-early philosophy.
- Failed profiles enter a 60s in-process cooldown and are skipped
  until it expires; chain exhaustion returns None per the existing
  failure contract.
- Config structure errors fail at construction (non-list fallback,
  unknown profile name, self-reference — H2-style). Environmental
  problems (e.g. fallback profile missing its API key) skip that
  fallback with a warning instead of blocking the primary.
- Unspecified attributes delegate to the primary client via
  __getattr__ (name/model/api_key/cancel), so DeepSeekChat and
  set_client keep working unchanged. stream_chat still delegates to
  the primary directly; first-token failover is L4b.

create_llm_client refactored: single-profile construction extracted
into _construct_profile_client, shared by the main path and the
fallback chain (flat-api path untouched).

Tests: new TestL4aChatCompletionFailover (15 cases: chain build,
validation, 429/5xx/connection failover, 401 no-failover, exhaustion,
cooldown skip/expiry, missing-key skip with warning, transient raise,
4xx None regression, stream delegation); full suite 330 passed.
2026-08-18 00:42:57 +08:00
qing d29bb5504a feat(aibox): L-batch profile polish (display name, prefix escape, temperature, reasoning aliases)
L1 display name: client.name now resolves to the profile's
display_name field, falling back to the profile key — the dialogue
context no longer labels Kimi/Qwen sessions "DeepSeek". The legacy
flat api section keeps "DeepSeek" (zero breakage).
_resolve_model_profile now returns (profile, model, profile_name).

L2 prefix escape: "model:NAME" forces bare-model semantics even when
a profile happens to be named NAME; "profile:NAME" forces profile
semantics and raises a clear error (listing available profiles) when
missing, instead of silently falling back to bare-name behavior.
Empty prefixes are rejected. Wired through --model / create_llm_client
/ subagent model params.

L3 profile temperature: new profile field "temperature" becomes the
client's default_temperature. chat_completion/stream_chat treat
temperature=None as "use profile default, else the old hardcoded
0.3/0.7", so explicit caller values still win. DeepSeekChat and
run_light_subagent resolve CLI > profile > constants.DEFAULT_TEMPERATURE.

L5 reasoning field aliases: _parse_delta recognizes
reasoning_content / reasoning / thinking / thinking_content (first
non-empty wins), so non-DeepSeek vendors' thinking streams are no
longer silently dropped.

L4 (cross-profile failover) intentionally deferred: streaming
failover needs request-replay semantics and is feature-level work.

Tests: new TestLBatch (16 cases); 7 existing unpack sites updated to
the 3-tuple. Full suite 315 passed.
2026-08-18 00:06:23 +08:00
qing a583f44d5d refactor(aibox): make profile reasoning_enabled a constructor param (M3)
Profile-level reasoning_enabled was attached as an instance attribute
after construction only when a profile matched; the base class never
declared it and consumers probed with double getattr. Any code that
accidentally set client.reasoning_enabled would silently override the
global switch.

The flag is now an explicit LLMClient constructor parameter with
three-state semantics: True/False (profile-level explicit, or the
global api.reasoning_enabled value concretized when the profile omits
the field) and None (no profile -> consumer falls back to global).
create_llm_client passes it at construction and no longer mutates the
client afterwards; DeepSeekChat._reasoning_enabled simplifies to a
single getattr probe that still tolerates clients without the attr.

Tests: TestM3ReasoningConstructor (4 cases) covering profile True,
profile-omitted -> global concretized, base default None, and the
None -> global fallback at the consumer; one legacy assertion updated
to the new contract (attribute exists, value None). Full suite 299
passed.
2026-08-17 23:58:45 +08:00
qing 5f3ca9a246 fix(aibox): sync compressor and snapshots on client swap via set_client (M1)
run_full_subagent replaced sub_chat.client by direct assignment after
construction, leaving ConversationCompressor (bound to the original
client in DeepSeekChat.__init__) and the api_key/name snapshots
pointing at the default model — compression requests silently kept
using the default endpoint/model while the dialogue itself used the
requested one.

DeepSeekChat gains set_client(): swaps self.client and refreshes
api_key, name, and compressor.llm_client in one place. run_full_subagent
now routes model overrides through it.

Tests: new TestM1SetClient (2 cases: full reference refresh on a real
DeepSeekChat instance, and run_full_subagent calling set_client with
the new client); full suite 295 passed.
2026-08-17 23:48:40 +08:00
qing 77262f8c3a fix(aibox): inject available model profile names into create_subagent (M2)
The LLM had no way to know which profile names exist in the models
config section; a hallucinated name fell through to bare-name semantics
(default endpoint + bogus model) and failed at request time with API
400.

SubAgentTool now builds the model hint dynamically from config via
_model_names_hint(): description and the model parameter's JSON Schema
description both list the profile names, with the default profile
(api.default_model) marked. Empty or malformed models sections keep
the original wording untouched (zero breakage); exceptions degrade to
no hint.

README: tool table note, test count 293, new v3.4 changelog entry
summarizing the H1/H2/H3/M2 profile-hardening batch.

Tests: new TestM2SubAgentModelHint (5 cases); full suite 293 passed.
2026-08-17 23:42:04 +08:00
qing 84a3f6dd5e fix(aibox): make --model effective in WebSocket server mode (H3)
--model was accepted by parse_args but only consumed inside
handle_once_mode, so `python aibox.py --model kimi` (server mode)
silently ignored the override.

The model now flows as a process-wide override:
main() -> EnhancedAIManager(model=...) -> DeepSeekChat(model=...) in
all three AI creation paths: add_ai (main dialogue),
WebSocketHandler._create_session_ai (per-connection sessions), and
TaskScheduler._create_task_ai (scheduled tasks). Manager logs
"CLI 模型覆盖已启用" at startup for observability. --once mode
plumbing is unchanged; --model=None keeps config-driven behavior.

Tests: new TestH3ModelPlumbing (6 cases) mocking DeepSeekChat to
assert the constructor kwarg across manager/session/task paths plus
parse_args; full suite 288 passed.
2026-08-17 23:37:59 +08:00
qing 0e9efe4895 fix(aibox): validate model profile schema at config source (H2)
Profiles previously had no validation, so config errors surfaced far
from their origin and cryptically:
- explicit profile missing `model` fell back to the profile NAME as the
  model (a profile named "local" would send "model": "local")
- default profile missing `model` with no model param produced
  "model": null -> API 400
- missing `api_url` surfaced as requests InvalidURL

_resolve_model_profile now runs _validate_profile (dict type + api_url)
before reading fields and _require_model on the effective model,
raising ValueErrors that name the profile, the missing field, and the
config.json path to fix. Bare model names still legally substitute for
a default profile's missing model field.

Tests: new TestH2SchemaValidation (6 cases: missing model/api_url,
null-model via default profile, bare-name substitution, non-dict
profile, e2e create_llm_client); full suite 282 passed.
2026-08-17 23:31:06 +08:00
qing 4480bce684 fix(aibox): never fall back to deepseek.key for model profile endpoints (H1)
~/.cert/deepseek.key is a DeepSeek-only credential; when a models profile
specified its own api_key_env (e.g. MOONSHOT_API_KEY) but the env var was
unset, resolve_api_key silently read that file and sent the DeepSeek key
as Bearer to a third-party endpoint (cross-vendor credential leak).

resolve_api_key gains allow_key_file (default True for the legacy flat
api section); create_llm_client passes allow_key_file=profile is None so
any profile path raises a clear RuntimeError instead, pointing users to
set the env var or use "" for local keyless endpoints.

Tests: new TestH1KeyIsolation (4 cases) with a mocked readable
deepseek.key proving the profile path errors out while the flat api
section still reads the file; full suite 276 passed.
2026-08-17 23:28:25 +08:00
qing 58725c8a12 docs: sync async inference loop into module docs
pybot --loop HEF 后端异步闭环(执行上一步动作与 NPU 推理并行)与 actpred predict_async 流水线落地后未同步文档,本次补齐:

- pybot/README:--loop 补异步推理路径说明(滞后一拍,~16ms→~12ms)

- actpred/README:Hailo 节新增 predict_async 小节(缓冲语义/2.6× 实测)

- 根 README、MODULES.md、ARCHITECTURE.md:pybot 行与机器人数据链同步

- MODULES.md:docs md 计数 70→76;ARCHITECTURE §9 挂 panel 设计稿规划项
2026-08-17 22:56:30 +08:00
qing 90f5544673 docs(aibox): changelog F2 (registry timeout cleanup + reader finalization)
Batch F bullet now split into F1/F2 entries; F2 covers unified
timeout-path cleanup of tool streaming attributes and reader-thread
finalization with the CPython stream-lock correction; test count 272.
2026-08-17 22:49:45 +08:00
qing 5bcf22f9a7 fix(aibox): registry timeout cleanup + reader thread finalization (F2/N6+N4)
N6: both streaming-forward timeout paths (deadline hit, join(5)
expiry) previously returned early and skipped the
tool.streaming_output / tool.output_queue cleanup, leaving the daemon
thread writing through the instance attribute — zombie output could
leak into the next session's queue once the attribute got reassigned.
Now unified through a timed_out flag with cleanup at the single exit;
stream_tool_timeout becomes an instance attribute (3600s default,
test-tunable).

N4: reader-thread finalization after timeout/grace break. First
implementation followed the audit suggestion (close stdout + join),
but PoC regression proved close() blocks: CPython stream objects hold
an internal lock, so closing while readline is blocked parks until
readline returns (PoC-1 regressed 3s -> 15.2s, exactly the
Start-Sleep duration). Corrected strategy: when reader already got
EOF, close stdout to release the pipe fd; when reader is still
blocked, only join(timeout=2) then give up (daemon-bounded, thread
ends naturally when the grandchild exits and EOF arrives), keeping
the main flow prompt. Audit report updated with this correction.

Tests: new tests/test_f2_fixes.py (5 cases), PoC regression 5/5
(PoC-1/4 back to ~5s), full suite 272 passed.
2026-08-17 22:47:37 +08:00
qing 6ef5d11d9d docs(aibox): sync README with Batch E/F security fixes
- feature list & tool table: set_proxy password semantics (memory-only,
  password_env for persistence)
- config example: fix stale flat proxy section to actual proxy.socks5
  structure with password_env; add plaintext-not-persisted note
- changelog: new v3.3 section covering Batch E (command exec protection
  chain) and Batch F (credential protection), test count 267
2026-08-17 22:41:24 +08:00
qing eb88a77238 fix(aibox): never persist SOCKS5 proxy password to disk (F1/N1, +N11)
- config.set_proxy gains password_env (aligned with api_key_env
  convention): config file persists only the env var name; plaintext
  password lives in memory for the current session only
- _save_config deep-copies and nulls proxy.socks5.password before
  json.dump, so every save path is sanitized (defense in depth)
- get_proxy resolves password as in-memory plaintext first, then via
  password_env from os.environ; downstream consumers (llm_client,
  web tool curl args) unchanged
- legacy migration on load: plaintext found on disk is kept in memory
  for the session, file is immediately rewritten sanitized
- SetProxyTool exposes password_env and reports password source
- registry debug logs mask password/api_key/token/secret/authorization
  kwargs (N11)

Tests: new tests/test_config.py (3 cases: no plaintext on save,
password_env resolution, legacy migration); full suite 267 passed.
Audit report updated with Batch F progress.
2026-08-17 22:37:30 +08:00
qing a833cd0d5f fix(memdb): expire semantics + Boost 1.83 portability + backlog & test harness
- expire 改为 update+refresh_ttl:键不存在返回 false,与 del/update 约定一致
- _require_string 适配 Boost 1.83(boost::json::string 已非 std::string)
- 新增 --backlog(默认 128,libmsg 默认 10 短连接突刺易丢连接)
- Makefile 补 HOST/PORT 默认值,test-memdb 用 PID 文件替代失效的 kill %1
- 补测试:复制构造/源销毁后副本有效/refresh 延长 TTL/expire 不存在键
2026-08-17 14:29:41 +00:00
qing 207137c0dd fix(libmemdb): deep-copy semantics so indices point into the copy's own list
默认复制只拷贝容器,keys/time_touch 中的迭代器仍指向源对象的 list 节点,
源销毁或改动后副本悬垂(UB)。改为保留时间戳的深拷贝构造 + copy-and-swap 赋值。
2026-08-17 14:29:39 +00:00
qing b2ecb8e12b fix(libmsg): close fd-reuse race silently killing short-lived connections
worker 收 EOF 失败后过早清除 busy 标记,事件循环对同一 fd 重复投递任务,
迟到的失败标记经 fd 复用落在新连接上被 _reap_failed 误杀(客户端收到空 EOF)。
失败路径改由 _reap_failed 统一清除 busy 并关闭;_handle_client 增加防重复投递守卫。
memdb 短连接压力测试错误率从 5-9% 降至 0。
2026-08-17 14:29:37 +00:00
qing 910ab8aa38 feat(pybot): 闭环异步推理——执行上一步动作与 NPU 推理并行
run_loop 自动检测 predictor.predict_async 走异步路径:提交推理后并行
执行上一步动作(滞后一拍,10Hz 下 100ms),隐藏真实 I2C 执行耗时;
同步路径不变。pybot 97 测试全过(新增异步路径 + 滞后一拍执行验证)。
2026-08-17 21:59:25 +08:00
qing 8de7f6696c feat(actpred): HailoPredictor 异步推理接口——run_async 流水线 2.6× 吞吐
新增 predict_async()(4 缓冲池 + 完成回调 + _AsyncJob.result),与同步
predict 语义/输出完全一致(重构公共 _prepare_inputs/_fill_bindings/
_postprocess)。真实 HEF 实测:连续吞吐 6.0ms vs 同步 15.5ms;扩展测试
10 seed 随机/60 任务乱序对应/混合调用/缓冲压力/300 次稳定性/4 线程并发
全部 0 差异,190 单测 OK。
2026-08-17 21:46:26 +08:00
qing 1348660cd8 docs(aibox): second-round audit re-review at HEAD 177a9ac
- Batch E fixes re-verified: H1/H3/M1 solid, H2 solid but with two
  residuals (reader thread leak, subagent timeout blind spot), M2
  incomplete (timeout paths skip tool attribute cleanup)
- Legacy M3/M4/L1/L3 confirmed unfixed; L1 found second instance at
  db.py:459 (get_immediate_memos)
- New findings: 1 high (SOCKS5 proxy password persisted in plaintext),
  9 medium (drain thread leak, event-loop blocking RLock, FTS5 keyword
  injection, streaming bypass of result length cap, persistent prompt
  injection storage, TOCTOU on mark_as_triggered, etc.), 7 low
- Updated compliance matrix and proposed Batch F fix plan (F1-F6)
2026-08-17 17:51:43 +08:00
qing 177a9ac9a6 fix(aibox): close audit findings H1-H3/M1-M2 (command exec protection chain)
- H1: CommandLineTool.execute_streaming now purely delegates to injected
  executor; removes 150-line duplicate Popen loop that bypassed 1MB cap,
  cancel() and timeout protections
- H2: thread the blocking readline into a daemon reader thread; main loop
  polls queue with 0.1s ticks so timeout/1MB checks fire even for silent
  commands (verified: 3s timeout on 15s sleep, was 15s+ before)
- H3: stdin=DEVNULL on both Popen sites; stdin-waiting commands now hit
  EOF instead of deadlocking the tool thread forever
- M1: heredoc delimiter uniquification in write_file guidance (content
  line equal to AIBOX_EOF no longer truncates into shell injection)
- M2: 3600s total deadline on registry streaming forward loop

Verification: PoC regression 5/5 PASS (timeout/1MB/cancel/delegation),
full unit suite 264 passed. Audit report updated with Batch E results.
2026-08-17 17:28:41 +08:00
qing ce142fe32f docs(aibox): sync once-mode docs (MODULES/ARCHITECTURE/dev-doc)
- MODULES.md/ARCHITECTURE.md:aibox 条目补 --once CLI 与 models 多模型档案(三处同步惯例)
- 开发文档新增第 7 节实施状态:四批提交映射、与计划的 4 处偏差(--once-timeout 改名/models 默认空/worker-only 精简 prompt/退出码 1 边界)、验证结果
- 原 7/8 节顺延为 8/9
2026-08-17 17:12:15 +08:00
qing d03dbc7f91 feat(aibox): batch D multi-model profiles + docs
- models 配置节:多 OpenAI Chat Completions 兼容端点按档案名切换,默认空 dict 零破坏回退旧 api 节
- --model 双语义:档案名(命中 models 节完整采用)/ 裸模型名(同端点换模型)
- resolve_api_key 档案级 env 三级语义:未配置回退 api.api_key_env / 显式名称 / 空串免密(Ollama/vLLM)
- 档案级 reasoning_enabled 附加 client 属性,DeepSeekChat._reasoning_enabled 优先读取
- llm_client 增加 openai_compatible/openai provider 别名
- README 更新:aibox/README 六处(特性/配置/多模型档案专节/CLI 参数/更新日志 v3.2/测试计数 264)+ 根 README aibox 小节
- 单测 17 项新增(test_models_profile.py),全套 264 项全过
2026-08-17 16:58:33 +08:00
qing a5af4bb404 feat(aibox): batch C once mode hardening
- SQLite WAL:subsystems/db.py 建库时 PRAGMA journal_mode=WAL,缓解多 CLI 进程并发写锁
- --worker-only CLI 参数:纯执行面模式(tool_profile=worker),注入精简 system prompt 避免人格/身份提示词污染
- --model / --temperature 覆盖参数:DeepSeekChat 构造透传,create_llm_client 与 stream_chat 两处生效
- 子代理工具失败透传:run_full_subagent(outcome) → SubAgentTool 打 _SUBAGENT_TOOL_FAILED_MARKER → _handle_tool_calls 检测置 orchestrator stats.tool_failed 并剥离标记
- Batch C 单测 5 项:worker profile 工具集、model/temperature 构造、子代理失败标记透传与剥离
2026-08-17 16:32:22 +08:00
qing 9e0d39c912 feat(aibox): batch B once mode refinements
- parse_args 新增 --no-save-memory / --show-reasoning
- DeepSeekChat.__init__ 增加 enable_memory 参数,干净禁用 MemoryTool(注册层不加入 schema)
- 修复 _think_and_respond_impl 压缩提示 print 污染 stdout:改走 output_callback(line)
- handle_once_mode 接入新参数:reasoning 默认静默 + show_reasoning 显式开启 + quiet 最高优先级
- 退出码 1 覆盖 orchestrator 工具异常(stats.tool_failed);worker 子代理非零码透传留 Batch C
- 新增 tests/test_once_mode.py(10 项:enable_memory 开关/回调分流/退出码状态/压缩提示不污染 stdout)
2026-08-17 16:00:45 +08:00
qing ce3fb7fc74 feat(aibox): add CLI once mode (--once/--quiet/--once-timeout)
- parse_args 新增 --once/--quiet/--once-timeout 参数
- 新增 handle_once_mode:stdout chunk 流式、stderr 过程行、SIGINT 三层取消、超时 Timer
- 退出码:0 成功 / 2 API 失败 / 3 中断 / 4 超时;与 WebSocket 零回归
- 配套开发文档:docs/reports/aibox_cli_once_mode_dev_doc.md
- 附带修复:memdb/test_memdb.py 并发测试硬编码端口
2026-08-17 15:45:06 +08:00
qing 2b133f1531 docs: cv2.LUT 优化验收测试报告 + 方案文档实测数据更新
200 次均值端到端 16.1ms(3.3×),分段 3.0/12.5/0.6ms——瓶颈已转移至
NPU 本身;一致性 7 类输入 + 8 seed 随机全 0 差异;10Hz 预算占用 16%。
2026-08-17 15:28:15 +08:00
qing 40523f6f8a feat(actpred): HailoPredictor cv2.LUT 快路径——归一化+量化合并,端到端 3.3×
帧预处理从 26.5ms 降至 1.7ms(15.8×),端到端 52.4 → 16.0ms。惰性导入 cv2、
无 cv2 自动回退 float 路径;非 uint8/非 3 通道/尺寸不匹配输入走 PIL 转换
与 float 路径语义一致(7 类输入实测 max_diff=0)。OpenCV 5.0 仅支持单通道
LUT,逐通道应用后 merge。依赖加入 requirements.txt。
2026-08-17 15:10:40 +08:00
qing 8a5162c912 docs: 修订 Hailo 预处理优化方案——查表法实测证伪,改合并乘加为主
原型实测:numpy 高级索引查表无加速(26.6 vs 28.5ms),预期 18-20ms 不成立。
主方案改为合并系数乘加(-5ms,0 LSB 等价),cv2.LUT 列为可选激进路径;
预期收益修正为 ~47ms(纯 numpy)/ ~25-30ms(cv2)。
2026-08-17 14:48:26 +08:00
qing 116d6b63bd docs: actpred Hailo 预处理优化方案——查表法合并归一化+量化
新 640×480 数据集实测端到端 52.4ms,预处理 35.9ms 占 68%(float 归一化
27.2ms + 量化绑定 9.1ms,存在中间 float 冗余)。方案:256 项查表合并
归一化与量化(数值等价),预计预处理降至 3-6ms、端到端 ~18-20ms。
2026-08-17 14:41:15 +08:00
qing 0fa32cb5e7 feat(actpred): HailoPredictor 适配 v4 onehot HEF——1864 logits 解码 + 固定列覆盖
v4 onehot 模型 HEF 输出 (1,1,1864),原回归假设(输出=action_dim)导致
输入分类与维度校验失败。_classify_inputs 改为 pred_token 先判(s[1]==1)、
其余 3 维归 history;_derive_dims 识别 TOTAL_BINS 输出;predict 经 bins.decode
期望值解码并固定权重 0 列(与 predict.py 同语义),新增 column_weights 参数。
_npu_test.py 指向 v4 并增加 onehot 参考分支,NPU 实测 4 角 PASS、
HEF vs torch max_diff 0.5°。
2026-08-17 14:29:21 +08:00
qing 63c29680ce docs: panel 微服务开发文档——背景视图聚合控件片段
设计稿:panel:9201 提供背景视图,各服务以 /panel-widget/<id>
暴露前端片段,面板拉取聚合后以 iframe 沙箱插入;含 postMessage
协议、BFF 转发、CSP 分层隔离与 M1-M4 实现阶段划分
2026-08-17 00:43:02 +00:00
qing 76afa37c6b fix(pybot): 录制 spk.F 恒 0 改为动态列查找——不依赖固定列号
原 recorder 按固定索引 SPK_F_COL=10 写 0,脚本列布局变化时会写错列;
改为 RobotStruct 录制前按设备名+参数名动态定位 frequency 列(无 Speaker
块返回 None 跳过),与模型固定输出契约一致,语义与 286e632 相同。
2026-08-16 16:17:55 +00:00
qing 293d71e86b fix(actpred): gen_calibration --deploy 的 is_range_mode 未导入即使用
import 在 if 分支内滞后于首次调用,fixed_range 归一化模型生成校准集
时直接 NameError;导入提前到 build_deploy_set 开头。
2026-08-16 16:17:10 +00:00
shufeng2012 d3a672a2a8 ✨ 添加代码块语言显示 2026-08-15 20:46:44 +08:00
qing d8b601ab9a feat(bot4cj/scripts): 12 个 mtc DSL 源声明 Camera——录制免注入
动作脚本统一加入 Camera("cam0",640,480,30,8)(record_stand.py 已有),
编译产物自带 @Camera 块,pybot --record 直接可录,取代 v2 的
"注入式录制"变体方案。手写 .mt 保持不动(bot4cj 共享、美化格式)。
2026-08-15 19:48:16 +08:00
qing 286e632288 fix(pybot): 录制 spk.F 列恒 0——与模型 FIXED_OUTPUT 对齐
F2 方案下推理端 spk.F 固定输出 0(bins.FIXED_OUTPUT),而脚本真值
可达 1000Hz,保留真值产生"模型 0 vs 数据 1000"的评估伪影(14.5K)。
录制时写常量 0 消除伪影;spk.delay 微步录制下恒 10.0 已与模型一致。
data_v3(296MB 可复现录制产物)加入 .gitignore。
2026-08-15 19:48:09 +08:00
qing 37feb09185 fix(actpred): 固定 0 列未被覆盖——掩码按权重而非常量值
fixed_zero_columns 对 spk.F 返回常量 0,调用处掩码 fixed!=0 将其
排除,spk.F 垃圾预测(69-423Hz)继续进闭环。改为按权重<=0 掩码
覆盖(含固定 0 的列)。修正后舵机闭环 MSE 14477 假象 → 293 真实
(hand 类 55K 是固定 0 vs 真值 1000 的指标伪影)。+1 回归测试。
2026-08-15 10:02:49 +00:00
qing 8a9a061662 fix(aibox): 子代理返回截断修复——收尾报告自生成替换外部摘要器 2026-08-15 14:27:27 +08:00
qing 0be9e5d0d2 feat(bot4cj/scripts): 13 个 .mt 脚本的 mtc DSL 源码(新格式)
std/stdd/tilt-r/l/f/b/lie-down/jump/rght-hand/left-hand/left-turn/t0/record_stand 全部转为 Python DSL(mtc 编译器输入),与原 .mt 执行级等价:经 RobotStruct dry-run + EpisodeRecorder 验证,actions.npy 形状一致且逐元素相等,record_stand 的 imu.npy (100,8) 形状一致,编译零告警。

关键语义依据:pybot 微步对舵机列线性插值、对 spk.F 列不插值(行级阶跃),分别对应 SetTo 斜坡段与 Beep 恒值段;首行 delay=200 恒等分解对应 stand() 的 10ms 瞬时到位段。原 .mt 保留未动,可用各文件头注释中的编译命令重新生成。
2026-08-15 14:25:59 +08:00
qing a80aaabffe feat(actpred): 动作历史角度抖动增强——对抗微步样本相关性
微动作插值使相邻样本高度相关,有效多样性远低于样本数(过拟合根因
之一)。--action-jitter >0 时对输入历史加高斯噪声(σ=比例×列物理
范围,如 0.01=舵机±1.8°),制造虚拟邻近状态;且与闭环自预测历史
噪声同源,增强反馈鲁棒性。目标保持干净不抖动。默认 0=关,+2 测试。
2026-08-15 06:15:16 +00:00
qing 01126a5786 feat(mtc): .mt 脚本编译器落地 + pybot 扬声器静音语义
mtc 新模块:命令式动作脚本(Python DSL)→ .mt 矩阵编译器,非阻塞时间轴 IR(重叠截断/rest 静止值)+ 10ms 栅格化,产物可由 pybot/bot4cj 直接执行;34 测试全绿(含 pybot parser e2e)。

pybot speaker.py:frequency<=0 改为静音节拍(仅 sleep 保时序,不再钳到 min_f 低鸣),对齐仓颉版 sleep 占位语义;mtc Beep 通道 rest=0,段前/段间/段后回落静音,两侧语义闭环。

修复 test_actpred_compatibility 过时断言(99c1bc1 首行分解为重复微步 19→20 帧时漏改,14→15)。

文档三处同步:根 README 回填 memdb/pybot/actpred 并新增 mtc(模块表+端口一览+章节),MODULES/ARCHITECTURE 同步 mtc,新建 mtc/README.md。
2026-08-15 13:54:47 +08:00
qing 205a60f039 docs: 同步 MODULES/ARCHITECTURE 至 services.yaml 现状(memdb:8011/pybot/libmemdb) 2026-08-15 13:54:47 +08:00
qing 13beba09b4 docs(actpred): 研究方向 R8 细化——三层流水线离线 RL + 样本级验证集报告
R8 细化:预训练→监督微调→离线 RL 流水线定稿;IQL/CQL/DT 选型对比;
IMU 奖励工程四分量(姿态稳定/抖动/平滑/任务进度);三阶段落地路径。
新增样本级验证集划分实验报告(整集 60 样本噪声大 → 样本级 142 样本
更稳,F2b 在更难验证集上取得更低 val)。实现:val_split_mode 默认
sample,dataset sample_indices 过滤 + zscore 统计口径保持训练集专属。
2026-08-15 03:52:27 +00:00
qing 8cf3dd2d93 fix(fsys): dnfs 页面底部中文输入时页面反复横跳
IME composition 期间每个拼音字母都触发 input 事件,autoResizeTextarea 的塌陷测量(height='auto' 后读 scrollHeight 强制整页同步布局)使页面总高度反复变化,滚到底部时 scrollTop 被浏览器钳制又回弹。

- composition 期间跳过高度调整,compositionend 后统一调一次

- 塌陷测量后补偿被钳制的滚动位置,非 IME 场景(英文/粘贴/删除)同样防跳
2026-08-15 10:46:50 +08:00
qing 9000bb4b04 docs(actpred): 模型 B zscore 等权稳定基线文档 + spk 两列固定常量
B 是六模型闭环评估中唯一稳定配置(2445/0.082),固化完整方案作为
部署基线与实验对照基准,并总结归一化系列失败机理(反馈回路需误差
有界且收缩)。配套实现:spk.F/spk.delay 权重归零、推理/反馈固定
常量(bins.FIXED_OUTPUT),拔除闭环发散源,为 B2(zscore+v2)与
F2(onehot+固定)铺路。
2026-08-15 02:01:04 +00:00
qing 7f21b65428 fix(aibox): FTS5 中文检索 + Token 压缩阈值生效——Compressor 抽离解耦
- db.py: FTS5 改用 trigram 分词(中文可检索),旧 unicode61 表自动迁移回灌;search_memories 增加 LIKE 兜底(%/_ 转义、MATCH 异常防护)
- conversation_compressor.py: should_compress 补齐 Token 阈值判断(原为死配置),Token 触发时按比例压缩早期消息
- ConversationCompressor 抽离至 subsystems,注入 llm_client 打破与 DeepSeekChat 循环引用;aibox.py 3517→3018 行
- 新增 21 项测试(中文检索/旧表迁移/LIKE 转义/token 估算/压缩流程/失败回退),全量 232 项通过
2026-08-15 09:51:59 +08:00
qing 26de527a09 feat(memdb×libmemdb): memdb 服务化落地——RPC 服务端 + 构建/测试/部署闭环
- libmemdb: 补全 flush() 惰性 TTL 驱逐;修复 explicit 误用/头文件缺失/
  时间戳碰撞误删;size()/get_db() 前 flush 消除脏读;三处双重哈希查找
  合并;index_keys 去冗余字段;update(refresh_ttl) 改原地重索引,
  免 remove+insert 的拷贝与重复 flush
- memdb: 重写 RPC 服务端(libmsg RpcServer, :8011, 9 路由, mutex 保护);
  补全 Makefile/_set.sh/_run.sh 与 --instance/--log-level CLI;
  SIGINT/SIGTERM 优雅退出;注册 services.yaml
- 测试: libmemdb 单元测试 22 例 + memdb RPC 集成测试 12 场景(含并发)
- 清理: 删除误合入的 db2/create_sqlite3.sql 旧版(scripts/ 下为权威版)
2026-08-15 09:27:12 +08:00
qing a18a7de1ca feat(actpred): 多头 one-hot 分类输出——18 组独立 softmax 头替代连续回归
分箱(bins.py):舵机 1°/箱 ×10(180 箱,匹配 0.75° 微动作插值粒度)、
spk.F/spk.delay 对数 32 箱 ×2,共 1864 logits;保留位无头。软标签
(onehot_sigma=1.0 高斯扩散)缓解小样本数据稀疏;期望值解码恢复
连续分辨率(箱生活在原始空间,无反变换放大)。

训练损失为逐头加权 CE(列权重沿用),闭环反馈经解码→范围归一化
回写历史;predict 输出仍为 18 维原始量纲,pybot 契约零变化。

结构性消除尺度放大(每箱等距)与物理界问题(箱天然有界),
为 A-E 归一化实验失败后的正解路线。要求 normalize_mode 为范围类,
与 predict_delta 互斥。测试 +10(含 CLI 映射回归)。
2026-08-15 00:17:58 +00:00
qing 99c1bc1739 fix(pybot×actpred): 微动作录制时间轴均匀化——首行分解为重复步,delay 列权重归零
pybot run():带 delay 的首行(无前序状态)不再单步执行,分解为
N=delay//10 个重复微步(插值退化恒等),录制数据全 10ms 均匀节拍,
delay 列恒 10ms——行级时长信息已隐式编码在步数中,消除首行 200ms
造成的时间采样不均(frame_len/history_len 等间隔假设被破坏)。

actpred default_column_weights:spk.delay 权重 0.2→0.0(步级恒值列
无信号,与保留位同级);spk.F 保持 0.2。

新录制数据集(952 样本,delay 恒 10)存于 data/pybot_record_v2(不入库)。
2026-08-14 21:56:01 +00:00
qing 1cf428f384 feat(actpred): log_range 归一化——spk 两列对数尺度修正精度错配
fixed_range 线性 min-max 下常用值挤在低端(10Hz→0.002),闭环反馈
中小绝对误差=大相对误差(模型 C/D 发散根因)。新增 normalize_mode=
log_range:spk.F/spk.delay 用 log10(1+x) 对数尺度(10Hz→0.27),
舵机列保持线性。全链路接入(dataset/pretrain/predict/onnx/hailo/
calibration/export),与 v3 审计 none 模式/元数据自动识别合并。
2026-08-14 21:38:14 +00:00
qing fc310c9081 Merge branch 'dev_shufeng2012' into main
合入 dev_shufeng2012 分支:libmemdb 头文件 KV 库 + memdb 重构 + db2 SQLite schema

冲突解决:
- _inst.sh:保留 main 版本(已有 captcha2/smtp3/liblogc,dev 新增项重叠)

合入内容:
- libmemdb/:C++20 模板头文件 Memdb<T,U>(list+unordered_map+multiset,TTL KV 存储)
- memdb/:重构为 libmemdb 客户端 + RPC 服务骨架(含 src/memdb.cpp,Makefile/_run.sh/_set.sh 空文件占位)
- db2/create_sqlite3.sql:Users/Notes/Logs 三表 schema(含四个索引)

测试:pybot 89 passed,actpred 148 passed/18 skipped(全绿)
2026-08-15 04:58:29 +08:00
qing 9179281420 fix(pybot×actpred): v3 审计 P2 清零——验收链 fixed_range + 导出断言 + overrun 节流
- v3-P2-1: _npu_test/_onnx_validate 按 normalize_mode 分支(fixed_range 免 stats,
  HailoPredictor 透传模式,torch 参考同口径反归一化)
- v3-P2-2: export_deploy/export_onnx 一致性 diff 超 1e-4 即 sys.exit(P2-f 闭环)
- v3-P2-3: run_loop overrun 告警节流(第1次+每100次),降 hz 建议仅 >10% 输出
- P2-a1: default_column_weights 扩展列(action_dim>18)默认 1.0
- P2-c: 补 predictor 异常清理路径测试(异常传播 + finally 落盘)
- P2-d/P2-e: Resize 口径与 resume scheduler T_max 语义文档化
- R2-4: _set.sh 安装后断言 CPU 版 torch,误装 CUDA 即拦截
测试: pybot 89 passed (+1)、actpred 148 passed/18 skipped
2026-08-14 23:01:56 +08:00
qing ad3a624b4c docs(actpred): P0-2 README 默认值对齐——normalize-actions 更正为默认 True
- --normalize-actions 默认值 False→True 并注明行为变更(与 config.py:14 对齐)
- --column-weights 注明始终生效(含未归一化训练,P2-a3)
- v3 审计报告同步:P0/P1 清零,P2-a3 一并闭环
2026-08-14 22:18:24 +08:00
qing 35ee614ca9 fix(pybot×actpred): v3 审计 P1 修复 3 项——零依赖承诺 + ONNX 元数据/残差全链路
- robot.py numpy 恢复函数级 lazy import,纯 .mt 脚本执行不再需要 numpy(v3-P1-1)
- export_onnx 写入训练配置元数据;onnx_infer 自动识别 normalize_mode/predict_delta 并支持残差加回(v3-P1-2)
- pybot --loop 补 .onnx 后端分支,zscore 缺 action_stats.npy 时告警(P2-a 闭环)
- 回归测试:actpred TestOnnxMetadata +7、pybot 零依赖导入 +1(88/148 全绿)
- 附 v3 审计报告:13 项 v2 修复回归核对 + 7 项新发现(P0-2 遗留 + P2/P3 六项)
2026-08-14 22:13:06 +08:00
qing b73628b2e5 docs: actpred 模型 C 实验报告——固定范围归一化+逐列加权的闭环发散根因
teacher forcing val 0.000394 三方最优,但闭环 overall_mse 19843、
error_growth 13.4 首次发散。根因:spk.F 尺度放大(1 归一化单位
=5000Hz)与 w=0.2 放松叠加 exposure bias,自预测反馈滚雪球。
排除保留位列失控与推理端实现 bug。加权方案部分有效(hand 类
episode 2.2-2.8× 优于模型 B),需叠加 R1-B rollout(模型 D 训练中)。
2026-08-14 13:19:09 +00:00
shufeng2012 9bbbc3fdf4 啥都没用的提交 2026-08-14 19:39:06 +08:00
qing 85bf68f505 feat(actpred): 固定范围归一化 + 逐列加权损失,附 pybot*actpred 审计修复
新增 action_norm.py 固定物理范围 min-max 归一化,无 stats 文件零状态部署;train.py 引入 _weighted_mse 逐列加权(舵机 1.0/spk 0.2/保留位 0.0)。审计修复: P0-1 pybot 读 normalize_mode, R2-1 NaN 阻断 best, P2-a 后端大小写不敏感, P1-3 utf-8-sig BOM, R1-1 data_dir 覆盖, R2-7 _run.sh 报错, R2-3 空 val 存 best.pt, P2-a2 误报警, R1-2 合并 parse_action_history, P2-b 合并 numpy import。评估修正 oob 物理界+error_growth 按 episode 中位数。推理部署全链支持 fixed_range。
2026-08-14 18:26:03 +08:00
qing adfa61aa3c docs: pybot×actpred 训练闭环评估实验报告与归一化逐列权重开发方案
实验报告:dry-run 批量录制 13 episodes/804 样本,未归一化 vs z-score
两模型对照训练,R4 闭环评估定位 spk.F 为主导误差列,记录 oob/error_growth
指标退化与 stats 路径 bug 修复。

开发方案:固定物理范围归一化(actpred 侧常量表,舵机 [0,180°])+
逐列加权损失,附评估器修正与 rollout/scheduled sampling 配套计划。
2026-08-14 08:59:53 +00:00
qing aa271e395a fix(pybot×actpred): 审计修复 7 项——Hailo 部署链语义对齐 + NaN 安全
P1-1: export_deploy.py forward() 从硬编码 3 帧改为 *inputs 动态解析
P1-2: hailo_infer.py 补 predict_delta 支持(残差模式 + --ckpt 自动检测)
P1-3: pybot _load_predictor 自动查找 pred_token.npy/action_stats.npy/
      companion checkpoint,恢复 Hailo 推理训练语义
P1-4: pretrain_mt.py random_images 每 batch 重新生成噪声,防 BN 退化
P2-1: predict.py/hailo_infer.py/robot.py 三层 NaN/Inf 防线
P2-3: recorder.py imu_dim 默认 8→0,避免外部直接使用时形状错误
P2-5: _npu_test.py imu_dim 移到函数定义前,消除执行顺序依赖
2026-08-14 16:23:05 +08:00
qing 7ec8c9d0e9 fix(actpred): 训练时 action stats 完整路径传给 val_ds,checkpoint 存裸文件名
normalize_actions 默认改为 True 后暴露的潜伏 bug:stats 保存到
checkpoint_dir 后,cfg 只留裸文件名,val_ds 按 cwd 解析导致
FileNotFoundError。现改为验证集用完整路径构建,checkpoint 配置
存裸文件名(推理端按 checkpoint 目录解析)。
同步修正 test_warn_init_from_mismatch 对默认值的隐式依赖。
2026-08-14 06:31:15 +00:00
qing af0ff56848 feat(pybot): 录制脚本 record_stand.mt——stand 多姿态序列(微动作分解)
基于 std.mt 扩展:@Camera 640x480 + MPU6050 绑定 + 5 行 @Action(delay=200)
生成 81 步 actpred 格式 episode(首行不分解 + 4×20 微步插值)
2026-08-14 06:26:25 +00:00
qing a2c3a579fd fix(actpred): 默认启用动作归一化,避免未归一化时 loss 虚高(几百→<1.0) 2026-08-14 12:01:26 +08:00
shufeng2012 96d9f96e6d 🎨 更改为libmemdb 2026-08-12 20:40:48 +08:00
shufeng2012 f606f7ec56 ✅ 通过黑盒测试 2026-08-12 20:11:33 +08:00
shufeng2012 02d95a2bd9 ⚡️ 将queue切换为set 2026-08-12 20:11:07 +08:00
shufeng2012 82875b3021 ✅ 内测版通过测试 2026-08-12 11:10:19 +08:00
shufeng2012 4d6e1e1fd4 🔨 更新可以直接使用_inst.sh的脚本 2026-08-12 09:57:55 +08:00
shufeng2012 8fe2afdfde 🚧 能编译了,makefile还没写 2026-08-10 11:15:26 +08:00
shufeng2012 4b7773daec 🙈 添加memdb的.gitignore 2026-08-10 11:14:56 +08:00
shufeng2012 6309358262 🚧 写了一半了memdb 2026-08-09 20:20:36 +08:00
126 changed files with 14607 additions and 1649 deletions

No files matched your search

+3
View File
@@ -25,3 +25,6 @@ waf/certs/
# 运行时日志(HailoRT 等) # 运行时日志(HailoRT 等)
hailort.log hailort.log
# 录制产物(可复现:mtc DSL → 编译 → pybot --record)
actpred/data_v3
+31 -1
View File
@@ -69,6 +69,7 @@
| [dnfs](dnfs/) | Python(Flask) | 8006 | ~~树状网络文件系统~~(遗留,已由 dnfs2 平替) | | [dnfs](dnfs/) | Python(Flask) | 8006 | ~~树状网络文件系统~~(遗留,已由 dnfs2 平替) |
| [db2](db2/) | C++20 | 8005 | 通用数据库服务,基于 pymsg 协议(TCP 长度前缀帧 + JSON)的 SQL-over-TCP,参数化查询防注入;内置集中式日志收集(Logs 表 + 自动清理) | | [db2](db2/) | C++20 | 8005 | 通用数据库服务,基于 pymsg 协议(TCP 长度前缀帧 + JSON)的 SQL-over-TCP,参数化查询防注入;内置集中式日志收集(Logs 表 + 自动清理) |
| [db](db/) | Python(pymsg) | 8005 | ~~通用数据库服务~~(遗留,已由 db2 平替) | | [db](db/) | Python(pymsg) | 8005 | ~~通用数据库服务~~(遗留,已由 db2 平替) |
| [memdb](memdb/) | C++20 | 8011 | 内存缓存数据库(libmemdb 模板库 + libmsg RPC),TTL 惰性驱逐,9 路由(ping/set/get/del/update/exists/size/show/expire),线程安全 + 优雅退出;暂无业务调用方,预留热点数据/会话缓存 |
| [ai_review](ai_review/) | Python(Flask) | 8007 | AI 内容审核服务,DeepSeek LLM + ChromaDB RAG,支持人工反馈纠偏 | | [ai_review](ai_review/) | Python(Flask) | 8007 | AI 内容审核服务,DeepSeek LLM + ChromaDB RAG,支持人工反馈纠偏 |
| [captcha2](captcha2/) | C++20 | 8009 | 验证码服务(pymsg/TCP),生成图形验证码(5 分钟过期、一次性),经 ca 的 `GET /captcha/image` 代理给浏览器 | | [captcha2](captcha2/) | C++20 | 8009 | 验证码服务(pymsg/TCP),生成图形验证码(5 分钟过期、一次性),经 ca 的 `GET /captcha/image` 代理给浏览器 |
| [captcha](captcha/) | Python(pymsg) | 8009 | ~~验证码服务~~(遗留,已由 captcha2 平替) | | [captcha](captcha/) | Python(pymsg) | 8009 | ~~验证码服务~~(遗留,已由 captcha2 平替) |
@@ -77,6 +78,9 @@
| [aibox](aibox/) | Python | 8765 | 独立 AI 智能体系统(DeepSeek + WebSocket),自带技能系统/记忆/任务调度,由 core 拉起但与主干微服务无调用关系 | | [aibox](aibox/) | Python | 8765 | 独立 AI 智能体系统(DeepSeek + WebSocket),自带技能系统/记忆/任务调度,由 core 拉起但与主干微服务无调用关系 |
| [BotOps](BotOps/) | Python | — | aibox 智能体 TUI 前端(Textual),WebSocket 连接 aibox,支持 Markdown 增量流式渲染、思考模式展示、任务独立侧栏、自动重连 | | [BotOps](BotOps/) | Python | — | aibox 智能体 TUI 前端(Textual),WebSocket 连接 aibox,支持 Markdown 增量流式渲染、思考模式展示、任务独立侧栏、自动重连 |
| [bot4cj](bot4cj/) | 仓颉(Cangjie) | — | 机器人硬件脚本解析器,解析 .mt 矩阵脚本通过 I2C 驱动 PCA9685 舵机 / MPU6050 陀螺仪,面向树莓派 Dashun 蜘蛛机器人平台(独立硬件项目) | | [bot4cj](bot4cj/) | 仓颉(Cangjie) | — | 机器人硬件脚本解析器,解析 .mt 矩阵脚本通过 I2C 驱动 PCA9685 舵机 / MPU6050 陀螺仪,面向树莓派 Dashun 蜘蛛机器人平台(独立硬件项目) |
| [pybot](pybot/) | Python | — | bot4cj 的 Python 迁移版:.mt 动作脚本解析器(零第三方依赖,与仓颉版逐语义对齐);支持执行 / `--dry-run` / `--record` 录制 actpred episode(含 imu.npy)/ `--loop` 实时预测闭环(HEF 后端自动异步,推理与执行并行) |
| [mtc](mtc/) | Python | — | 命令式动作脚本(Python DSL)→ .mt 编译器:非阻塞时间轴 + 10ms 栅格展开,输出可由 pybot/bot4cj 直接执行;零第三方依赖 |
| [actpred](actpred/) | Python(PyTorch) | — | 动作预测模型(图像 + 动作历史 → 下一动作),bot4cj/pybot 的「大脑」配套;两阶段训练(.mt 预训练 → episode 微调),ONNX/Hailo HEF 部署(实验) |
| [lci](lci/) | C++ | — | 嵌入式帧缓冲图形界面(LCI),基于 `/dev/fb0` 直接操作显存,无需 X11/Wayland,矢量字体、图片渲染、实时数据可视化,面向树莓派等嵌入式环境 | | [lci](lci/) | C++ | — | 嵌入式帧缓冲图形界面(LCI),基于 `/dev/fb0` 直接操作显存,无需 X11/Wayland,矢量字体、图片渲染、实时数据可视化,面向树莓派等嵌入式环境 |
### core(核心模块 · 进程托管服务) ### core(核心模块 · 进程托管服务)
@@ -171,6 +175,12 @@
- 安全机制:表名白名单(字母数字下划线)、`sqlite3_bind_text` 参数绑定、仅反射已有表无法建删表、RpcServer 异常脱敏。 - 安全机制:表名白名单(字母数字下划线)、`sqlite3_bind_text` 参数绑定、仅反射已有表无法建删表、RpcServer 异常脱敏。
- 配置全部经 CLI 传递(CLI > 环境变量 > 默认值,原 `db.ini` 迁入 `core/services.yaml` 的 CLI args),不依赖 confs;详见 [db2/README.md](db2/README.md)。 - 配置全部经 CLI 传递(CLI > 环境变量 > 默认值,原 `db.ini` 迁入 `core/services.yaml` 的 CLI args),不依赖 confs;详见 [db2/README.md](db2/README.md)。
### memdb(内存缓存数据库)
- 基于 [libmemdb](libmemdb/)(C++20 header-only 模板库:三索引存储——`std::list` 主存 + `unordered_map` 键索引 + `multiset` TTL 索引,TTL 惰性驱逐)+ [libmsg](libmsg/) RPC 服务,监听 127.0.0.1:8011。
- 9 路由:`ping` / `set` / `get` / `del` / `update` / `exists` / `size` / `show` / `expire`;`std::mutex` 保护线程安全,SIGINT/SIGTERM 优雅退出。
- 已纳入 `core/services.yaml` 自启动;暂无业务调用方,预留热点数据/会话缓存;详见 [memdb/README.md](memdb/README.md)。
### ai_review(AI 内容审核服务) ### ai_review(AI 内容审核服务)
- 基于 DeepSeek LLM + ChromaDB 向量检索的 RAG 审核架构。 - 基于 DeepSeek LLM + ChromaDB 向量检索的 RAG 审核架构。
@@ -225,7 +235,8 @@
- 一套相对独立的单智能体产品,基于 DeepSeek + WebSocket(:8765),由 `core` 拉起但与 waf / gateway / dnfs2 等主干微服务无调用关系。 - 一套相对独立的单智能体产品,基于 DeepSeek + WebSocket(:8765),由 `core` 拉起但与 waf / gateway / dnfs2 等主干微服务无调用关系。
- 自带技能系统(`skills/`)、任务调度器、记忆管理、前端页面与语音客户端(`voice_client.py`)。 - 自带技能系统(`skills/`)、任务调度器、记忆管理、前端页面与语音客户端(`voice_client.py`)。
- 默认模型 `deepseek-v4-flash`,配置在 `config.json`;详见 [aibox/README.md](aibox/README.md)。 - 默认模型 `deepseek-v4-flash`,配置在 `config.json`;`models` 配置节可挂多个 OpenAI 兼容端点按档案名切换(档案级 reasoning/温度/展示名/`fallback` 失败转移),`--once` 支持无 WebSocket 的 CLI 一次性对话;详见 [aibox/README.md](aibox/README.md)。
- **四轮安全审计修复(2026-08,Batch E/F/G/H/I 共 60+ 项)**:命令执行保护链(1MB/超时/中断)、凭据脱敏(代理密码不落盘、日志打码、Bearer 脱敏)、取消传播(子代理/情绪子代理/调度器超时)、DB 事务与 FTS5 转义、POSIX 进程组终止等;审计报告见 [docs/reports/aibox_audit_20260817.md](docs/reports/aibox_audit_20260817.md)。
### BotOps(aibox 智能体 TUI 前端) ### BotOps(aibox 智能体 TUI 前端)
@@ -243,6 +254,21 @@
- 附带数学库(矩阵运算、NN/CNN、Adam 优化器)、CUDA GPU 加速 kernel 和 Lua 硬件模块(ADS1115 / ADS1256 / SPI)。 - 附带数学库(矩阵运算、NN/CNN、Adam 优化器)、CUDA GPU 加速 kernel 和 Lua 硬件模块(ADS1115 / ADS1256 / SPI)。
- 与主干微服务完全独立,需部署在树莓派上独立运行。 - 与主干微服务完全独立,需部署在树莓派上独立运行。
### pybot(机器人动作脚本解析器 · Python 迁移版)
- [bot4cj](#bot4cj机器人硬件脚本解析器) 的 Python 迁移版,零第三方依赖(纯标准库),与仓颉版逐语义对齐(含占位符 `-` 递归继承、微步分解、块序依赖)。
- 三种运行模式:脚本执行 `run()`、`--record` 录制 actpred episode(images + actions.npy + imu.npy,≤4 个 MPU6050 按块行序拼接)、`--loop` 实时预测闭环(daemon 线程队列非阻塞取帧,overrun 告警;HEF 后端自动走异步推理——执行上一步动作与 NPU 推理并行,滞后一拍)。
- 扬声器静音语义:`frequency <= 0` 不出声仅保持节拍(对齐仓颉版 sleep 占位语义)。
- Windows 开发机可跑测试(fcntl mock + 符号链接 fallback);详见 [pybot/README.md](pybot/README.md)。
### mtc(.mt 脚本编译器)
- 命令式动作脚本(Python DSL)→ `.mt` 矩阵脚本编译器,零第三方依赖;产物可直接由 pybot / bot4cj 执行。
- 管线:script.py `exec` 注入命名空间 → 事件收集 → 时间轴 IR(相对→绝对、重叠截断)→ 10ms 栅格化 → out.mt。
- 非阻塞时间轴语义:`Action`/`SetTo`/`Beep` 在当前游标处注册运动段即返回,`Delay`/`Wait` 推进游标,并发运动天然表达;`dur` 须为 10 的倍数(与 pybot 微步节拍一致)。
- 扬声器通道静止值回落 0(段前/段间空隙/段后 = 静音),与 pybot speaker 静音语义对齐;`spk.frequency`/`spk.delay` 列自动成对补齐。
- 用法:`python -m mtc walk.py -o walk.mt`(须在 `mtc/` 目录或设 `PYTHONPATH=mtc` 运行);详见 [mtc/README.md](mtc/README.md)。
### lci(嵌入式帧缓冲图形界面) ### lci(嵌入式帧缓冲图形界面)
- LCI(Linux Chinese Interface)是基于 Linux 帧缓冲设备(`/dev/fb0`)的图形界面系统,**无需 X11 或 Wayland**,直接 `mmap` 操作显存,专为树莓派等嵌入式环境设计。 - LCI(Linux Chinese Interface)是基于 Linux 帧缓冲设备(`/dev/fb0`)的图形界面系统,**无需 X11 或 Wayland**,直接 `mmap` 操作显存,专为树莓派等嵌入式环境设计。
@@ -291,8 +317,12 @@
| 8007 | ai_review | 127.0.0.1 | AI 审核(仅内部调用) | | 8007 | ai_review | 127.0.0.1 | AI 审核(仅内部调用) |
| 8009 | captcha2 | 127.0.0.1 | 验证码服务(pymsg/TCP,经 ca 代理) | | 8009 | captcha2 | 127.0.0.1 | 验证码服务(pymsg/TCP,经 ca 代理) |
| 8008 | smtp | 127.0.0.1 | 邮件服务(仅内部调用,注册验证码邮件后端) | | 8008 | smtp | 127.0.0.1 | 邮件服务(仅内部调用,注册验证码邮件后端) |
| 8011 | memdb | 127.0.0.1 | 内存缓存数据库(libmsg RPC,仅内部调用,暂无业务调用方) |
| 8765 | aibox | 127.0.0.1 | AI 智能体(WebSocket,独立产品) | | 8765 | aibox | 127.0.0.1 | AI 智能体(WebSocket,独立产品) |
| — | bot4cj | — (树莓派 I2C) | 机器人硬件脚本解析器(独立硬件项目,不联网) | | — | bot4cj | — (树莓派 I2C) | 机器人硬件脚本解析器(独立硬件项目,不联网) |
| — | pybot | — (树莓派) | bot4cj 的 Python 迁移版(.mt 解析/录制/--loop 实时闭环,独立硬件项目) |
| — | mtc | — | 命令式动作脚本 → .mt 编译器(开发期工具,无端口) |
| — | actpred | — | 动作预测模型训练/部署(实验,无端口) |
| — | lci | — (树莓派 /dev/fb0) | 嵌入式帧缓冲图形界面(独立硬件项目,不联网) | | — | lci | — (树莓派 /dev/fb0) | 嵌入式帧缓冲图形界面(独立硬件项目,不联网) |
| — | BotOps | — | aibox 智能体 TUI 前端(WebSocket 客户端,连接 aibox:8765) | | — | BotOps | — | aibox 智能体 TUI 前端(WebSocket 客户端,连接 aibox:8765) |
+37 -9
View File
@@ -44,6 +44,13 @@ bash _run.sh train --data-dir ./data --epochs 100 --action-dim 18 \
# R2 姿态反馈:--imu-dim 8 启用独立 IMU token(数据需含 imu.npy,见"数据格式") # R2 姿态反馈:--imu-dim 8 启用独立 IMU token(数据需含 imu.npy,见"数据格式")
bash _run.sh train --data-dir ./data --epochs 100 --action-dim 18 --imu-dim 8 bash _run.sh train --data-dir ./data --epochs 100 --action-dim 18 --imu-dim 8
# 归一化+逐列加权:固定物理范围归一化(免 stats 文件)+ 舵机列损失权重上浮
bash _run.sh train --data-dir ./data --epochs 100 --action-dim 18 \
--normalize-actions --normalize-mode fixed_range
# 自定义列权重(逗号分隔 action_dim 个浮点;默认舵机1.0/spk0.2/保留位0.0)
bash _run.sh train --data-dir ./data --epochs 100 --normalize-actions \
--normalize-mode fixed_range --column-weights "1,1,1,1,1,1,1,1,1,1,0.2,0.2,0,0,0,0,0,0"
# 推理(--images 按时间顺序旧→新传入 frame_len 个图像路径,冷启动时可重复传同一帧; # 推理(--images 按时间顺序旧→新传入 frame_len 个图像路径,冷启动时可重复传同一帧;
# --action-history 格式:冒号分隔各步,每步 action_dim 个值,共 history_len 步) # --action-history 格式:冒号分隔各步,每步 action_dim 个值,共 history_len 步)
bash _run.sh --model checkpoints/best_inference.pt \ bash _run.sh --model checkpoints/best_inference.pt \
@@ -160,7 +167,9 @@ recorder.flush()
| `--epochs` | 100 | 训练轮数 | | `--epochs` | 100 | 训练轮数 |
| `--lr` | 0.001 | 学习率 | | `--lr` | 0.001 | 学习率 |
| `--batch-size` | 4 | 批次大小(默认分辨率 640×480;更高分辨率需调小,小分辨率可调大) | | `--batch-size` | 4 | 批次大小(默认分辨率 640×480;更高分辨率需调小,小分辨率可调大) |
| `--normalize-actions` | False | 动作归一化(`--no-normalize-actions` 关闭) | | `--normalize-actions` | True | 动作归一化(`--no-normalize-actions` 关闭;2026-08-14 起默认开启——复现旧行为需显式关闭) |
| `--normalize-mode` | zscore | 归一化方式:`zscore`=数据统计(依赖 `action_stats.npy`);`fixed_range`=固定物理范围 min-max→[0,1](常量表见 `action_norm.py`,免 stats 文件,越界 clamp 防御) |
| `--column-weights` | 舵机1.0/spk0.2/保留位0.0 | 逐列损失权重(逗号分隔 action_dim 个浮点;仅影响训练损失,推理端无感知;**始终生效**——包括 `--no-normalize-actions` 的未归一化训练) |
| `--dropout` | 0.1 | Dropout 率 | | `--dropout` | 0.1 | Dropout 率 |
| `--weight-decay` | 0.0001 | 权重衰减 | | `--weight-decay` | 0.0001 | 权重衰减 |
| `--val-split` | 0.15 | 验证集比例 | | `--val-split` | 0.15 | 验证集比例 |
@@ -170,7 +179,7 @@ recorder.flush()
| `--num-workers` | 2 | DataLoader 工作进程数 | | `--num-workers` | 2 | DataLoader 工作进程数 |
| `--checkpoint-dir` | ./checkpoints | 检查点保存目录 | | `--checkpoint-dir` | ./checkpoints | 检查点保存目录 |
| `--init-from` | 无 | 预训练 checkpoint 路径,作为微调起点(strict=False) | | `--init-from` | 无 | 预训练 checkpoint 路径,作为微调起点(strict=False) |
| `--resume` | 无 | 从 last.pt 断点续训练(恢复 model/optimizer/scheduler/epoch) | | `--resume` | 无 | 从 last.pt 断点续训练(恢复 model/optimizer/scheduler/epoch;scheduler 的 T_max 一并恢复,resume 时改 `--epochs` 不影响 LR 余弦曲线,只影响循环边界) |
| `--save-config` | 无 | 保存配置到 JSON 文件 | | `--save-config` | 无 | 保存配置到 JSON 文件 |
| `--predict-delta` | False | R1-C 残差预测(target 改 Δ,推理端自动加回 a_{t-1}) | | `--predict-delta` | False | R1-C 残差预测(target 改 Δ,推理端自动加回 a_{t-1}) |
| `--rollout-len` | 1 | R1-B 多步展开步数 K(>1 时 scheduled sampling) | | `--rollout-len` | 1 | R1-B 多步展开步数 K(>1 时 scheduled sampling) |
@@ -185,21 +194,26 @@ recorder.flush()
| 指标 | 含义 | 判读 | | 指标 | 含义 | 判读 |
|------|------|------| |------|------|------|
| `overall_mse` | 全部滚动步的平均 MSE | 越低越好 | | `overall_mse` | 全部滚动步的平均 MSE | 越低越好 |
| `error_growth` | 末段(后25%)误差 / 首段(前25%)误差 | >1 表示误差随步数发散(闭环不稳) | | `error_growth` | 末段(后25%)误差 / 首段(前25%)误差(按 episode 分别计算后取中位数) | >1 表示误差随步数发散(闭环不稳) |
| `error_curve` | 误差随步数的增长曲线 | 看发散拐点 | | `error_curve` | 误差随步数的增长曲线 | 看发散拐点 |
| `smoothness_ratio` | 预测平滑度 / 真值平滑度 | <1 表示比真值更平滑(残差模式的预期收益) | | `smoothness_ratio` | 预测平滑度 / 真值平滑度 | <1 表示比真值更平滑(残差模式的预期收益) |
| `oob_rate` | 预测落在训练数据经验范围外的比例 | 越高越危险(舵机越界风险) | | `oob_rate` | 预测落在物理范围外的比例 | 越高越危险(舵机越界风险) |
物理可行性用数据分布的经验界(min/max + 10% margin)而非硬编码范围——18 列混含 越界判定用固定物理界(`action_norm.py` 常量表:舵机 [0,180]、spk.F [0,5000] 等),
舵机角度/频率/延时/预留,无统一硬限位。`--compare` 用于 A/B 对比(如 R1 开/关)。 "越物理界"比"越经验界"更符合安全语义,且恒值列不会使经验界退化。`--compare` 用于 A/B 对比(如 R1 开/关)。
滚动复现闭环口径:模型带 imu 输入时按 episode 的 `imu.npy` 真值姿态逐步喂入 滚动复现闭环口径:模型带 imu 输入时按 episode 的 `imu.npy` 真值姿态逐步喂入
(缺失则零向量,与 pybot 闭环无 `@MPU6050_BIND` 块时的行为一致)。 (缺失则零向量,与 pybot 闭环无 `@MPU6050_BIND` 块时的行为一致)。
## 部署注意 ## 部署注意
- 启用 `--normalize-actions` 训练的模型,其 `action_stats.npy` 保存在 checkpoint 同目录; - 启用 `--normalize-actions` 且 `--normalize-mode zscore`(默认)训练的模型,其 `action_stats.npy`
部署时**必须将 stats 文件与 checkpoint 一起拷贝**(推理端优先按 checkpoint 内记录的路径查找, 保存在 checkpoint 同目录;部署时**必须将 stats 文件与 checkpoint 一起拷贝**(推理端优先按
找不到时回退到 checkpoint 同目录的 `action_stats.npy`)。 checkpoint 内记录的路径查找,找不到时回退到 checkpoint 同目录的 `action_stats.npy`)。
- `--normalize-mode fixed_range` 用固定物理范围 min-max→[0,1](常量表见 `action_norm.py`),
**免 stats 文件**,消除 stats 路径类 bug 的整类风险;推理端(predict/onnx_infer/hailo_infer)
从 checkpoint/ONNX 元数据自动识别归一化方式(含 predict_delta 残差模式),无需额外参数。
注:旧版导出的 ONNX 无元数据,onnx_infer 回退 zscore + 非残差,需显式
`--normalize-mode/--predict-delta` 或重新导出。
- `actions.npy` 的列数必须与 `--action-dim` 一致;图像与动作帧数不一致时训练会打印 WARNING 并按较少者使用。 - `actions.npy` 的列数必须与 `--action-dim` 一致;图像与动作帧数不一致时训练会打印 WARNING 并按较少者使用。
## 编程 API ## 编程 API
@@ -297,3 +311,17 @@ python3 dfc_compile.py # 产物 checkpoints/actpred_pretrain.hef
`--pred-token` 喂入学习值、`--stats` 喂入归一化统计、`--imu` 喂入姿态。 `--pred-token` 喂入学习值、`--stats` 喂入归一化统计、`--imu` 喂入姿态。
环境调研与 API 踩坑记录见 环境调研与 API 踩坑记录见
[docs/reports/actpred_hailo_hat2_inference_research_20260813.md](../docs/reports/actpred_hailo_hat2_inference_research_20260813.md)。 [docs/reports/actpred_hailo_hat2_inference_research_20260813.md](../docs/reports/actpred_hailo_hat2_inference_research_20260813.md)。
### 异步推理(`predict_async`,run_async 流水线)
`HailoPredictor.predict_async(images, action_history, pred_token=None, imu=None)`
提交异步推理并立即返回 `_AsyncJob`,需要结果时 `job.result(timeout)` 阻塞获取:
- 与同步 `predict` 语义/输出完全一致(公共 `_prepare_inputs/_fill_bindings/_postprocess`)
- 内部维护 4 个绑定缓冲池 + 完成回调;在途任务超过缓冲数时提交会阻塞等待
空闲缓冲,`job.result` 超时**不会**释放缓冲(缓冲由完成回调释放)
- 连续提交(≥2 个在途)时 NPU 保持满负荷:实测连续吞吐 6.0ms vs 同步
15.5ms(约 2.6×);扩展测试覆盖乱序取结果/混合调用/缓冲压力/300 次
稳定性/4 线程并发,输出与同步 0 差异
- pybot `--loop` 检测到 `predict_async` 即自动走异步闭环(推理期间并行
执行上一步动作),见 [pybot/README.md](../pybot/README.md)
+55 -26
View File
@@ -4,7 +4,7 @@
4 角测试(真实/全零帧 × 真实/全零历史)+ 与 PyTorch 参考对比。 4 角测试(真实/全零帧 × 真实/全零历史)+ 与 PyTorch 参考对比。
HEF 尺寸/布局从 vstream info 自动推导,checkpoint 用 HEF 尺寸/布局从 vstream info 自动推导,checkpoint 用
checkpoints/pretrain_best_inference.pt。 checkpoints/best_inference.pt(v4 onehot,fixed_range)。
""" """
import sys import sys
import time import time
@@ -20,8 +20,8 @@ from config import parse_checkpoint_config
from model import ActionPredictor from model import ActionPredictor
from hailo_infer import HailoPredictor from hailo_infer import HailoPredictor
HEF_PATH = "checkpoints/actpred_pretrain.hef" HEF_PATH = "checkpoints/actpred_v4.hef"
CKPT = "checkpoints/pretrain_best_inference.pt" CKPT = "checkpoints/best_inference.pt"
IMG_DIR = "data/episode_0000/images" IMG_DIR = "data/episode_0000/images"
hef = HEF(HEF_PATH) hef = HEF(HEF_PATH)
@@ -40,11 +40,11 @@ imgs_real = [np.array(Image.open(f"{IMG_DIR}/000{i}.jpg").convert("RGB"))
imgs_zero = [np.zeros_like(imgs_real[0]) for _ in range(3)] imgs_zero = [np.zeros_like(imgs_real[0]) for _ in range(3)]
hist_real = np.array([ hist_real = np.array([
[45, 0, 45, 0, 45, 0, 45, 0, 90, 90, 10, 200, 0, 0, 0, 0, 0, 0], [45, 0, 45, 0, 45, 0, 45, 0, 90, 90, 0, 10, 0, 0, 0, 0, 0, 0],
[45, 0, 45, 0, 45, 0, 45, 0, 90, 90, 0, 20, 0, 0, 0, 0, 0, 0], [45, 0, 45, 0, 45, 0, 45, 0, 90, 90, 0, 10, 0, 0, 0, 0, 0, 0],
[45, 0, 45, 0, 45, 0, 45, 0, 90, 90, 0, 0, 0, 0, 0, 0, 0, 0], [45, 0, 45, 0, 45, 0, 45, 0, 90, 90, 0, 10, 0, 0, 0, 0, 0, 0],
[45, 0, 45, 0, 45, 0, 45, 0, 90, 90, 0, 0, 0, 0, 0, 0, 0, 0], [45, 0, 45, 0, 45, 0, 45, 0, 90, 90, 0, 10, 0, 0, 0, 0, 0, 0],
[45, 0, 45, 0, 45, 0, 45, 0, 90, 90, 0, 0, 0, 0, 0, 0, 0, 0], [45, 0, 45, 0, 45, 0, 45, 0, 90, 90, 0, 10, 0, 0, 0, 0, 0, 0],
], dtype=np.float32) ], dtype=np.float32)
hist_zero = np.zeros((5, 18), dtype=np.float32) hist_zero = np.zeros((5, 18), dtype=np.float32)
@@ -55,12 +55,15 @@ model = ActionPredictor(cfg.model, cfg.data, pretrained=False)
model.load_state_dict(ck["model_state_dict"]) model.load_state_dict(ck["model_state_dict"])
model.eval() model.eval()
pred_learned = model.pred_token.data.unsqueeze(1).numpy() # (1,1,1,d) pred_learned = model.pred_token.data.unsqueeze(1).numpy() # (1,1,1,d)
imu_dim = cfg.data.imu_dim
# 归一化模型:torch 参考在喂入前 z-score;HailoPredictor 传 stats_path # 归一化模型:torch 参考在喂入前归一化;HailoPredictor 传 normalize_mode
# 由封装内部归一化(hist_real/hist_zero 保持原始值,避免双重归一化) # 由封装内部归一化(hist_real/hist_zero 保持原始值,避免双重归一化)。
# fixed_range 免 action_stats.npy(该模式训练不产出统计文件)。
import os import os
stats_path = None stats_path = None
if cfg.data.normalize_actions: norm_mode = cfg.data.normalize_mode if cfg.data.normalize_actions else "none"
if norm_mode == "zscore":
stats_path = cfg.data.action_stats_path or os.path.join( stats_path = cfg.data.action_stats_path or os.path.join(
os.path.dirname(os.path.abspath(CKPT)), "action_stats.npy") os.path.dirname(os.path.abspath(CKPT)), "action_stats.npy")
if not os.path.isabs(stats_path): if not os.path.isabs(stats_path):
@@ -69,6 +72,8 @@ if cfg.data.normalize_actions:
if not os.path.exists(stats_path): if not os.path.exists(stats_path):
raise FileNotFoundError(f"统计文件不存在: {stats_path}") raise FileNotFoundError(f"统计文件不存在: {stats_path}")
print(f"HailoPredictor stats: {stats_path}") print(f"HailoPredictor stats: {stats_path}")
elif norm_mode == "fixed_range":
print("fixed_range 归一化:免 action_stats.npy,按固定物理范围 min-max")
from torchvision import transforms from torchvision import transforms
tf = transforms.Compose([ tf = transforms.Compose([
@@ -81,9 +86,12 @@ tf = transforms.Compose([
def torch_pred(imgs, hist, use_learned_pred=True, imu=None): def torch_pred(imgs, hist, use_learned_pred=True, imu=None):
x = torch.stack([tf(Image.fromarray(im)) for im in imgs]).unsqueeze(0) x = torch.stack([tf(Image.fromarray(im)) for im in imgs]).unsqueeze(0)
h = torch.tensor(hist).unsqueeze(0) h = torch.tensor(hist).unsqueeze(0)
if stats_path is not None: if norm_mode == "zscore" and stats_path is not None:
stats = np.load(stats_path) stats = np.load(stats_path)
h = (h - torch.tensor(stats[0])) / torch.tensor(stats[1]) h = (h - torch.tensor(stats[0])) / torch.tensor(stats[1])
elif norm_mode == "fixed_range":
from action_norm import normalize_action
h = torch.from_numpy(normalize_action(h.numpy(), cfg.data.action_dim))
ii = None ii = None
if imu_dim > 0: if imu_dim > 0:
# 与 HailoPredictor 同口径:原始姿态 → 内部归一化前需手动归一 # 与 HailoPredictor 同口径:原始姿态 → 内部归一化前需手动归一
@@ -91,20 +99,34 @@ def torch_pred(imgs, hist, use_learned_pred=True, imu=None):
ii = torch.from_numpy(normalize_imu(imu, imu_dim)).unsqueeze(0) ii = torch.from_numpy(normalize_imu(imu, imu_dim)).unsqueeze(0)
if use_learned_pred: if use_learned_pred:
with torch.no_grad(): with torch.no_grad():
return model(x, h, ii).squeeze(0).numpy() out = model(x, h, ii).squeeze(0).numpy()
saved = model.pred_token.data.clone() else:
model.pred_token.data.zero_() saved = model.pred_token.data.clone()
with torch.no_grad(): model.pred_token.data.zero_()
out = model(x, h, ii).squeeze(0).numpy() with torch.no_grad():
model.pred_token.data.copy_(saved) out = model(x, h, ii).squeeze(0).numpy()
model.pred_token.data.copy_(saved)
if cfg.data.onehot:
# 多头 logits → 期望值解码 → 原始量纲(与 predict.py 同语义,
# 含权重 0 列固定常量),对比前无需反归一化
from bins import decode as bin_decode, fixed_zero_columns
from action_norm import default_column_weights
out = bin_decode(out, cfg.data.action_dim).astype(np.float32)
w = (np.asarray(cfg.data.column_weights, dtype=np.float32)
if cfg.data.column_weights is not None
else default_column_weights(cfg.data.action_dim))
fixed = fixed_zero_columns(w, cfg.data.action_dim)
out[w <= 0] = fixed[w <= 0]
return out return out
# R2 方案一:imu_dim>0 时每步传原始姿态(HailoPredictor 内部归一化) # R2 方案一:imu_dim>0 时每步传原始姿态(HailoPredictor 内部归一化)
imu_dim = cfg.data.imu_dim
imu_real = np.array([5.0, -3.0] + [0.0] * 6, dtype=np.float32) imu_real = np.array([5.0, -3.0] + [0.0] * 6, dtype=np.float32)
with HailoPredictor(HEF_PATH, stats_path=stats_path) as p: # normalize_actions=False 时 normalize_mode 无意义(stats_path=None 即不归一化)
with HailoPredictor(HEF_PATH, stats_path=stats_path,
normalize_mode=(norm_mode if norm_mode != "none"
else "zscore")) as p:
print(f"\ndims: frame_len={p.frame_len} img={p.image_width}x{p.image_height} " print(f"\ndims: frame_len={p.frame_len} img={p.image_width}x{p.image_height} "
f"history={p.history_len} action_dim={p.action_dim}") f"history={p.history_len} action_dim={p.action_dim}")
t0 = time.perf_counter() t0 = time.perf_counter()
@@ -130,12 +152,19 @@ with HailoPredictor(HEF_PATH, stats_path=stats_path) as p:
ref = torch_pred(imgs_real, hist_real, True, imu_real) ref = torch_pred(imgs_real, hist_real, True, imu_real)
ref_zero_pred = torch_pred(imgs_real, hist_real, False, imu_real) ref_zero_pred = torch_pred(imgs_real, hist_real, False, imu_real)
# torch 参考是模型原始 z 空间输出;HailoPredictor 带 stats 时已反归一化 # torch 参考是模型原始归一化空间输出;HailoPredictor 已反归一化
# 到原始动作空间,比较前须同空间对齐,否则归一化模型下 max_diff 无意义 # 到原始动作空间,比较前须同空间对齐,否则归一化模型下 max_diff
if stats_path is not None: # 无意义(onehot 模型 torch_pred 已 decode 为原始量纲,无需再处理)
s = np.load(stats_path) if not cfg.data.onehot:
ref = ref * s[1] + s[0] if norm_mode == "fixed_range":
ref_zero_pred = ref_zero_pred * s[1] + s[0] from action_norm import denormalize_action
ref = denormalize_action(ref, cfg.data.action_dim)
ref_zero_pred = denormalize_action(ref_zero_pred,
cfg.data.action_dim)
elif stats_path is not None:
s = np.load(stats_path)
ref = ref * s[1] + s[0]
ref_zero_pred = ref_zero_pred * s[1] + s[0]
print(f"torch learned_pred : {np.round(ref[:6], 3)}...") print(f"torch learned_pred : {np.round(ref[:6], 3)}...")
print(f"torch zero_pred : {np.round(ref_zero_pred[:6], 3)}...") print(f"torch zero_pred : {np.round(ref_zero_pred[:6], 3)}...")
print(f"HEF vs torch(learned) max_diff={np.abs(a - ref).max():.4f}" print(f"HEF vs torch(learned) max_diff={np.abs(a - ref).max():.4f}"
+9 -2
View File
@@ -25,7 +25,8 @@ model.load_state_dict(ck["model_state_dict"])
model.eval() model.eval()
action_stats = None action_stats = None
if cfg.data.normalize_actions: norm_mode = cfg.data.normalize_mode if cfg.data.normalize_actions else "none"
if norm_mode == "zscore":
import os import os
stats_path = cfg.data.action_stats_path or os.path.join( stats_path = cfg.data.action_stats_path or os.path.join(
os.path.dirname(os.path.abspath(CKPT)), "action_stats.npy") os.path.dirname(os.path.abspath(CKPT)), "action_stats.npy")
@@ -37,6 +38,8 @@ if cfg.data.normalize_actions:
raise ValueError(f"action stats 形状应为 (2, {cfg.data.action_dim})," raise ValueError(f"action stats 形状应为 (2, {cfg.data.action_dim}),"
f"实际为 {action_stats.shape}: {stats_path}") f"实际为 {action_stats.shape}: {stats_path}")
print(f"history 将 z-score 归一化({stats_path})") print(f"history 将 z-score 归一化({stats_path})")
elif norm_mode == "fixed_range":
print("history 将 fixed_range min-max 归一化(action_norm.py,免 stats)")
sess = ort.InferenceSession(ONNX) sess = ort.InferenceSession(ONNX)
in_names = [i.name for i in sess.get_inputs()] in_names = [i.name for i in sess.get_inputs()]
@@ -62,7 +65,11 @@ hist_real = np.array([
], dtype=np.float32) ], dtype=np.float32)
hist_zero = np.zeros((5, 18), dtype=np.float32) hist_zero = np.zeros((5, 18), dtype=np.float32)
if action_stats is not None: if norm_mode == "fixed_range":
from action_norm import normalize_action
hist_real = normalize_action(hist_real, cfg.data.action_dim)
hist_zero = normalize_action(hist_zero, cfg.data.action_dim)
elif action_stats is not None:
hist_real = (hist_real - action_stats[0]) / action_stats[1] hist_real = (hist_real - action_stats[0]) / action_stats[1]
hist_zero = (hist_zero - action_stats[0]) / action_stats[1] hist_zero = (hist_zero - action_stats[0]) / action_stats[1]
+5 -1
View File
@@ -11,6 +11,10 @@ elif [ "${1:-}" = "pretrain" ]; then
elif [ "${1:-}" = "export" ]; then elif [ "${1:-}" = "export" ]; then
shift shift
exec python3 export_onnx.py "$@" exec python3 export_onnx.py "$@"
else elif [ "${1:-}" = "predict" ]; then
shift
exec python3 predict.py "$@" exec python3 predict.py "$@"
else
echo "未知子命令: ${1:-}(可用: train / pretrain / export / predict)" >&2
exit 1
fi fi
+11
View File
@@ -16,6 +16,17 @@ pip install onnxscript onnxruntime -i https://pypi.tuna.tsinghua.edu.cn/simple
pip check pip check
mkdir -p checkpoints data mkdir -p checkpoints data
# R2-4 防呆:确认装到的是 CPU 版 torch——requirements.txt 含 torch,
# 若被镜像源重装成 CUDA 版(无 GPU 部署会被 CUDA 依赖拖垮)在此拦截
python3 -c "
import torch
assert torch.version.cuda is None, (
f'检测到 CUDA 版 torch({torch.__version__}),'
'请用 _set.sh 的 CPU 源重装: pip install torch torchvision '
'--index-url https://download.pytorch.org/whl/cpu --force-reinstall')
print('torch CPU build OK:', torch.__version__)
"
# Pre-download MobileNetV2 weights for offline fine-tuning (optional, skip if backbone=simple) # Pre-download MobileNetV2 weights for offline fine-tuning (optional, skip if backbone=simple)
python3 -c " python3 -c "
import torch, sys, os import torch, sys, os
+97
View File
@@ -0,0 +1,97 @@
"""动作向量固定物理范围归一化 —— 训练(dataset/pretrain_mt)与推理
(predict / onnx_infer / hailo_infer / gen_calibration / export_deploy)共用
18 列动作向量的物理量纲(pybot 侧契约):
0-9 舵机角度 [0, 180]°(pybot/hd/servo.py MAX_ANGLE=180.0 校准后全行程)
10 spk.F 扬声器频率 [0, 5000] Hz(@Speaker max_f=5000)
11 spk.delay 延时 [0, 2000] ms(脚本实用上限,std.mt 用 10-500)
12-17 保留位(恒 0,hi-lo=1.0 使归一化后仍为 0;损失权重 0 不参与优化)
min-max 映射到 [0,1]:x_norm = clip((x - lo) / (hi - lo), 0, 1)。
与 z-score 的区别:固定物理范围不依赖 action_stats.npy 数据统计文件,
部署链零状态;clamp 防御真实数据越界(越界值截断到 [0,1],不扩散)。
仅依赖 numpy,保证 hailo_infer.py(HAT 部署环境无 torch)可直接复用。
"""
import numpy as np
# 各列物理下界(当前全 0;保留数组以便未来引入非零下界列)
ACTION_LO = np.zeros(18, dtype=np.float32)
# 各列物理上界(18 列标准布局;action_dim 不同用 action_bounds() 适配)
ACTION_HI = np.array(
[180.0] * 10 + # 0-9 舵机角
[5000.0, 2000.0] + # 10 spk.F,11 spk.delay
[1.0] * 6, # 12-17 保留位(恒 0,span=1.0 保持归一化后为 0)
dtype=np.float32)
# 归一化方式:fixed_range 线性 min-max;log_range 在 spk 两列上用
# log10(1+x) 对数尺度,修正低端聚集导致的精度错配
# (10Hz 在线性尺度占 0.002、对数尺度占 0.27)
RANGE_MODES = ("fixed_range", "log_range")
LOG_COLS = (10, 11) # spk.F / spk.delay
def is_range_mode(mode: str) -> bool:
"""normalize_mode 是否为固定范围类(fixed_range / log_range)"""
return mode in RANGE_MODES
def action_bounds(action_dim: int = 18):
"""(lo, hi) 物理界:action_dim ≤ 18 取前缀;> 18 的超出列按 [0, 1] 补足"""
if action_dim <= len(ACTION_LO):
return ACTION_LO[:action_dim], ACTION_HI[:action_dim]
pad = action_dim - len(ACTION_LO)
return (np.concatenate([ACTION_LO, np.zeros(pad, dtype=np.float32)]),
np.concatenate([ACTION_HI, np.ones(pad, dtype=np.float32)]))
def normalize_action(action, action_dim: int = None,
mode: str = "fixed_range") -> np.ndarray:
"""原始动作向量 → [0,1] 归一化向量(clamp 防御越界值)
action: (..., action_dim) 任意前导维;action_dim 缺省取末维长度。
mode="fixed_range":全列线性 min-max;
mode="log_range":LOG_COLS 列用 log10(1+x)(负值先截 0),其余列线性。
"""
a = np.asarray(action, dtype=np.float32)
lo, hi = action_bounds(action_dim or a.shape[-1])
out = np.clip((a - lo) / (hi - lo), 0.0, 1.0)
if mode == "log_range":
for c in LOG_COLS:
if c < a.shape[-1]:
hc = float(hi[c])
col = np.clip(a[..., c], 0.0, None)
out[..., c] = np.log10(col + 1.0) / np.log10(hc + 1.0)
return out
def denormalize_action(norm_action, action_dim: int = None,
mode: str = "fixed_range") -> np.ndarray:
"""[0,1] 归一化向量 → 原始动作向量(不 clamp,保留模型外推能力)
mode="log_range" 时 LOG_COLS 列指数还原:x = 10^(n·log10(hi+1)) - 1。
"""
n = np.asarray(norm_action, dtype=np.float32)
lo, hi = action_bounds(action_dim or n.shape[-1])
out = n * (hi - lo) + lo
if mode == "log_range":
for c in LOG_COLS:
if c < n.shape[-1]:
hc = float(hi[c])
out[..., c] = (np.power(10.0, n[..., c] * np.log10(hc + 1.0))
- 1.0)
return out
def default_column_weights(action_dim: int = 18) -> np.ndarray:
"""逐列损失权重默认表:舵机 1.0(安全关键);spk.F / spk.delay 0.0
(只影响声音/节奏不影响运动,推理端固定常量,见 bins.FIXED_OUTPUT);
保留位 0.0(恒 0 纯噪声)。action_dim>18 的扩展列默认 1.0:超出
当前 18 列语义布局的通道视为新执行器,静默给 0 会让其完全不参与
训练(P2-a1)。"""
w = np.zeros(action_dim, dtype=np.float32)
w[:min(10, action_dim)] = 1.0
if action_dim > 18:
w[18:] = 1.0
return w
+134
View File
@@ -0,0 +1,134 @@
"""多头 one-hot 分箱 —— 18 维连续动作向量与多组 one-hot 分类目标互转
分箱设计(actpred_onehot_multihead_dev_plan_20260814.md):
0-9 舵机角:均匀 1°/箱 → 180 箱(匹配 0.75° 微动作插值数据粒度)
10 spk.F:对数分箱 log10(1+x) ∈ [0, log10(5001)] → 32 箱
11 spk.delay:对数分箱 log10(1+x) ∈ [0, log10(2001)] → 32 箱
12-17 保留位:无头(恒 0,不参与分类)
编码/解码与 action_norm 的关系:箱生活在原始物理空间(舵机箱=角度),
decode 直接产出原始量纲,无需反归一化;输入历史仍按 normalize_mode
归一化(与现状一致),仅监督目标换为 one-hot。
软标签:encode(x, sigma) 把真值 one-hot 高斯扩散到邻近箱,缓解
804→1864 分类面的数据稀疏(多数箱极少被访问)。
仅依赖 numpy,推理/部署文件可直接复用。
"""
import numpy as np
# 每组 one-hot 的箱数(按列索引;保留位无头)
BINS_PER_COL = [180] * 10 + [32, 32]
# 有头的列
HEAD_COLS = tuple(range(12))
# 各组起始位置(组 i 占 [START[i], START[i+1]))
START = np.concatenate([[0], np.cumsum(BINS_PER_COL)])
TOTAL_BINS = int(START[-1]) # 1864
LOG_COLS = (10, 11) # 对数分箱列
LOG_HI = {10: 5000.0, 11: 2000.0}
# 权重 0 列的输出固定值(原始量纲):spk.F 固定 0(只影响声音不影响
# 运动,闭环中持续发声无意义);spk.delay 在步级数据中恒 10ms(微动作
# 分解的隐式节拍)。无监督头在闭环中会输出中段垃圾污染历史,
# 推理/反馈时直接固定为常量。其余权重 0 列(保留位)固定 0。
FIXED_OUTPUT = {10: 0.0, 11: 10.0}
def fixed_zero_columns(weights, action_dim: int = 18) -> np.ndarray:
"""按列权重表返回权重<=0 列的固定输出向量(其余列 0)
与 default_column_weights 联动:列权重 0 → 推理输出固定为
FIXED_OUTPUT 常量(缺省 0),避免无监督头在闭环反馈中输出垃圾。
"""
w = np.asarray(weights, dtype=np.float32)
fixed = np.zeros(action_dim, dtype=np.float32)
for i in range(min(action_dim, len(w))):
if w[i] <= 0.0:
fixed[i] = FIXED_OUTPUT.get(i, 0.0)
return fixed
def bin_edges(col: int) -> np.ndarray:
"""列 col 的箱边界数组(长度 bins+1),原始量纲递增"""
n = BINS_PER_COL[col]
if col in LOG_COLS:
hi = LOG_HI[col]
lo_log, hi_log = 0.0, np.log10(hi + 1.0)
# 对数空间均匀边界 → 原始空间箱边界
return np.power(10.0, np.linspace(lo_log, hi_log, n + 1)) - 1.0
lo, hi = 0.0, 180.0
return np.linspace(lo, hi, n + 1)
def bin_centers(col: int) -> np.ndarray:
"""列 col 的箱中心(decode 期望值的坐标),原始量纲"""
e = bin_edges(col)
return 0.5 * (e[:-1] + e[1:])
def encode(x: np.ndarray, action_dim: int = 18, sigma: float = 0.0,
out: np.ndarray = None) -> np.ndarray:
"""连续动作向量 (..., D) → 多组 one-hot (..., TOTAL_BINS)
sigma > 0 时启用软标签:真值箱高斯扩散 exp(-d²/2σ²),d=与真值箱的
距离(按箱索引);归一化后总和为 1。保留位列(12+)无头,恒不编码。
"""
a = np.asarray(x, dtype=np.float32)
flat = a.reshape(-1, a.shape[-1])
n_rows = flat.shape[0]
if out is None:
out = np.zeros(a.shape[:-1] + (TOTAL_BINS,), dtype=np.float32)
out_flat = out.reshape(n_rows, TOTAL_BINS)
rows = np.arange(n_rows)
for col in HEAD_COLS:
if col >= flat.shape[1]:
break
edges = bin_edges(col)
idx = np.clip(np.searchsorted(edges, flat[:, col], side="right") - 1,
0, BINS_PER_COL[col] - 1)
s, e = START[col], START[col + 1]
if sigma <= 0.0:
out_flat[rows, s + idx] = 1.0
else:
n = BINS_PER_COL[col]
d = np.abs(np.arange(n)[None, :] - idx[:, None])
g = np.exp(-(d * d) / (2.0 * sigma * sigma))
g = g / g.sum(axis=1, keepdims=True)
out_flat[:, s:s + n] = g
return out
def decode(logits: np.ndarray, action_dim: int = 18,
temperature: float = 1.0) -> np.ndarray:
"""多头 logits (..., TOTAL_BINS) → 连续动作向量 (..., D),原始量纲
逐头 softmax → 期望值 Σ pᵢ·箱中心(分辨率超越箱宽);保留位列填 0。
temperature 用于探索采样(=1 为正常推理)。
"""
l = np.asarray(logits, dtype=np.float32)
out = np.zeros(l.shape[:-1] + (action_dim,), dtype=np.float32)
for col in HEAD_COLS:
if col >= action_dim:
break
s, e = START[col], START[col + 1]
z = l[..., s:e] / max(temperature, 1e-6)
z = z - z.max(axis=-1, keepdims=True) # 数值稳定
p = np.exp(z)
p = p / p.sum(axis=-1, keepdims=True)
out[..., col] = p @ bin_centers(col)
return out
def argmax_decode(logits: np.ndarray, action_dim: int = 18) -> np.ndarray:
"""argmax 解码:每头取最大概率箱中心(确定性,量化到箱宽)"""
l = np.asarray(logits, dtype=np.float32)
out = np.zeros(l.shape[:-1] + (action_dim,), dtype=np.float32)
for col in HEAD_COLS:
if col >= action_dim:
break
s, e = START[col], START[col + 1]
idx = np.argmax(l[..., s:e], axis=-1)
out[..., col] = bin_centers(col)[idx]
return out
+101 -2
View File
@@ -1,6 +1,6 @@
import argparse import argparse
from dataclasses import dataclass, asdict, field from dataclasses import dataclass, asdict, field
from typing import Optional from typing import List, Optional
@dataclass @dataclass
@@ -11,8 +11,33 @@ class DataConfig:
history_len: int = 5 history_len: int = 5
frame_len: int = 3 frame_len: int = 3
data_dir: str = "./data" data_dir: str = "./data"
normalize_actions: bool = False normalize_actions: bool = True
action_stats_path: Optional[str] = None action_stats_path: Optional[str] = None
# 归一化方式(仅 normalize_actions=True 时生效):
# "zscore" 数据统计 z-score(现状,依赖 action_stats.npy)
# "fixed_range" 固定物理范围 min-max → [0,1](免 stats 文件,零状态部署,
# 常量表见 action_norm.py;越界值 clamp 防御)
# "log_range" 同上,但 spk.F/spk.delay 两列用 log10(1+x) 对数尺度,
# 修正低端聚集导致的精度错配(见实验报告)
normalize_mode: str = "zscore"
# 逐列损失权重(仅训练损失用,推理端无感知):None → action_norm
# .default_column_weights(action_dim)(舵机 1.0 / spk 0.2 / 保留位 0.0)。
# 显式指定时长度须等于 action_dim。
column_weights: Optional[List[float]] = None
# 多头 one-hot 分类输出(bins.py 分箱):True 时监督目标为多组 one-hot
# (舵机 1°/箱 ×10、spk 对数 32 箱 ×2,共 1864 logits),损失用加权 CE,
# 推理端期望值解码。要求 normalize_actions=True 且 normalize_mode 为
# 范围类(fixed_range/log_range)——历史输入沿用范围归一化,免 stats。
# 与 predict_delta 互斥(残差在连续空间定义)。
onehot: bool = False
# one-hot 软标签高斯宽度(箱):0=硬标签;>0 真值扩散到邻近箱,
# 缓解小样本下分类面数据稀疏(1°/箱 × 804 样本多数箱极少被访问)
onehot_sigma: float = 1.0
# 动作历史角度抖动增强(原始量纲比例):>0 时对输入历史加
# 高斯噪声,σ = action_jitter × 列物理范围(舵机 0.01 → ±1.8°)。
# 制造虚拟邻近状态,对抗微步插值样本高度相关导致的有效多样性
# 不足;且与闭环自预测历史噪声同源,增强反馈鲁棒性。目标不变。
action_jitter: float = 0.0
# R2 方案一:MPU6050 姿态作为独立 IMU token 注入(序列 F+H+2)。 # R2 方案一:MPU6050 姿态作为独立 IMU token 注入(序列 F+H+2)。
# 0 = 关闭(默认,序列保持 F+H+1,旧 checkpoint 完全兼容); # 0 = 关闭(默认,序列保持 F+H+1,旧 checkpoint 完全兼容);
# 8×N = N 个传感器的 roll,pitch,ax,ay,az,gx,gy,gz 按 pybot 脚本 # 8×N = N 个传感器的 roll,pitch,ax,ay,az,gx,gy,gz 按 pybot 脚本
@@ -40,6 +65,11 @@ class TrainConfig:
lr: float = 1e-3 lr: float = 1e-3
weight_decay: float = 1e-4 weight_decay: float = 1e-4
val_split: float = 0.15 val_split: float = 0.15
# 验证集划分方式:
# "episode" 整集划分(防数据泄漏,但单 episode 验证噪声大)
# "sample" 样本级随机划分(默认:多集混合、验证集更大更稳,
# 代价是相邻高相关样本泄漏使 val 略乐观)
val_split_mode: str = "sample"
checkpoint_dir: str = "./checkpoints" checkpoint_dir: str = "./checkpoints"
patience: int = 15 patience: int = 15
num_workers: int = 2 num_workers: int = 2
@@ -84,6 +114,22 @@ def build_argparser(description: str = "") -> argparse.ArgumentParser:
parser.add_argument("--data-dir", type=str, default=None) parser.add_argument("--data-dir", type=str, default=None)
parser.add_argument("--normalize-actions", action=argparse.BooleanOptionalAction, default=None) parser.add_argument("--normalize-actions", action=argparse.BooleanOptionalAction, default=None)
parser.add_argument("--action-stats-path", type=str, default=None) parser.add_argument("--action-stats-path", type=str, default=None)
parser.add_argument("--normalize-mode", type=str, default=None,
choices=["zscore", "fixed_range", "log_range"],
help="动作归一化方式:zscore=数据统计(依赖 action_stats.npy);"
"fixed_range=固定物理范围 min-max→[0,1](免 stats 文件);"
"log_range=spk 两列对数尺度(免 stats 文件)")
parser.add_argument("--onehot", action=argparse.BooleanOptionalAction,
default=None,
help="多头 one-hot 分类输出(bins.py 分箱,舵机 1°/箱)")
parser.add_argument("--onehot-sigma", type=float, default=None,
help="one-hot 软标签高斯宽度(箱,0=硬标签,默认 1.0)")
parser.add_argument("--action-jitter", type=float, default=None,
help="动作历史角度抖动(原始量纲比例,0=关;"
"如 0.01 = 舵机 ±1.8°)")
parser.add_argument("--column-weights", type=str, default=None,
help="逐列损失权重(逗号分隔 action_dim 个浮点),"
"缺省用默认表(舵机1.0/spk0.2/保留位0.0);仅影响训练损失")
parser.add_argument("--imu-dim", type=int, default=None, parser.add_argument("--imu-dim", type=int, default=None,
help="R2 方案一:MPU6050 姿态维度(0=关闭,默认;" help="R2 方案一:MPU6050 姿态维度(0=关闭,默认;"
"8×N=N 个传感器拼接(≤4 个),序列变 F+H+2)") "8×N=N 个传感器拼接(≤4 个),序列变 F+H+2)")
@@ -99,6 +145,10 @@ def build_argparser(description: str = "") -> argparse.ArgumentParser:
parser.add_argument("--lr", type=float, default=None) parser.add_argument("--lr", type=float, default=None)
parser.add_argument("--weight-decay", type=float, default=None) parser.add_argument("--weight-decay", type=float, default=None)
parser.add_argument("--val-split", type=float, default=None) parser.add_argument("--val-split", type=float, default=None)
parser.add_argument("--val-split-mode", type=str, default=None,
choices=["episode", "sample"],
help="验证集划分:episode=整集(防泄漏但噪声大);"
"sample=样本级随机(默认,更大更稳)")
parser.add_argument("--checkpoint-dir", type=str, default=None) parser.add_argument("--checkpoint-dir", type=str, default=None)
parser.add_argument("--patience", type=int, default=None) parser.add_argument("--patience", type=int, default=None)
parser.add_argument("--num-workers", type=int, default=None) parser.add_argument("--num-workers", type=int, default=None)
@@ -156,6 +206,42 @@ def validate_config_values(data_cfg: DataConfig, model_cfg: ModelConfig,
f"attn_dim={model_cfg.attn_dim} 必须能被 attn_heads=" f"attn_dim={model_cfg.attn_dim} 必须能被 attn_heads="
f"{model_cfg.attn_heads} 整除") f"{model_cfg.attn_heads} 整除")
if not (data_cfg.normalize_mode in ("zscore", "fixed_range", "log_range")):
raise ValueError(
f"normalize_mode={data_cfg.normalize_mode!r} 非法,"
f"应为 'zscore'、'fixed_range' 或 'log_range'")
if data_cfg.onehot:
if not data_cfg.normalize_actions:
raise ValueError("onehot=True 要求 normalize_actions=True"
"(历史输入须范围归一化)")
if not (data_cfg.normalize_mode in ("fixed_range", "log_range")):
raise ValueError(
f"onehot=True 要求 normalize_mode 为范围类,"
f"实际为 {data_cfg.normalize_mode!r}")
if train_cfg.predict_delta:
raise ValueError("onehot 与 predict_delta 互斥"
"(残差定义在连续空间)")
if not (0.0 <= data_cfg.action_jitter <= 1.0):
raise ValueError(
f"action_jitter={data_cfg.action_jitter} 应在 [0, 1] 内")
if not (0.0 <= data_cfg.onehot_sigma <= 10.0):
raise ValueError(
f"onehot_sigma={data_cfg.onehot_sigma} 应在 [0, 10] 内")
if data_cfg.column_weights is not None:
w = data_cfg.column_weights
if len(w) != data_cfg.action_dim:
raise ValueError(
f"column_weights 长度 {len(w)} 应等于 action_dim="
f"{data_cfg.action_dim}")
if any((not isinstance(v, (int, float))) or isinstance(v, bool)
or not (0.0 <= v <= 1e6) for v in w):
raise ValueError(
f"column_weights 元素应为 [0, 1e6] 内的数值,实际: {w}")
if not any(v > 0 for v in w):
raise ValueError("column_weights 全 0:损失恒 0,训练无梯度")
if train_cfg is not None: if train_cfg is not None:
t_checks = [ t_checks = [
("batch_size", train_cfg.batch_size, 1, 65536, int), ("batch_size", train_cfg.batch_size, 1, 65536, int),
@@ -194,6 +280,11 @@ def warn_init_from_mismatch(init_cfg: dict, current: Config):
print(f"WARNING: init-from checkpoint 的 normalize_actions=" print(f"WARNING: init-from checkpoint 的 normalize_actions="
f"{init_norm} 与当前运行 {current.data.normalize_actions} " f"{init_norm} 与当前运行 {current.data.normalize_actions} "
f"不一致,输入分布不匹配会损失预训练收益(见 README 预训练说明)") f"不一致,输入分布不匹配会损失预训练收益(见 README 预训练说明)")
init_mode = init_data.get("normalize_mode", "zscore") # 旧 checkpoint 无此键 → zscore
if init_norm and current.data.normalize_actions and init_mode != current.data.normalize_mode:
print(f"WARNING: init-from checkpoint 的 normalize_mode={init_mode} "
f"与当前运行 {current.data.normalize_mode} 不一致,"
f"归一化空间不同会损失预训练收益")
init_dim = init_data.get("action_dim") init_dim = init_data.get("action_dim")
if init_dim is not None and init_dim != current.data.action_dim: if init_dim is not None and init_dim != current.data.action_dim:
print(f"WARNING: init-from checkpoint 的 action_dim={init_dim} " print(f"WARNING: init-from checkpoint 的 action_dim={init_dim} "
@@ -249,6 +340,11 @@ def apply_overrides(cfg: Config, args: argparse.Namespace) -> Config:
"data_dir": ("data", "data_dir"), "data_dir": ("data", "data_dir"),
"normalize_actions": ("data", "normalize_actions"), "normalize_actions": ("data", "normalize_actions"),
"action_stats_path": ("data", "action_stats_path"), "action_stats_path": ("data", "action_stats_path"),
"normalize_mode": ("data", "normalize_mode"),
"column_weights": ("data", "column_weights"),
"onehot": ("data", "onehot"),
"onehot_sigma": ("data", "onehot_sigma"),
"action_jitter": ("data", "action_jitter"),
"imu_dim": ("data", "imu_dim"), "imu_dim": ("data", "imu_dim"),
"backbone": ("model", "cnn_backbone"), "backbone": ("model", "cnn_backbone"),
"cnn_feature_dim": ("model", "cnn_feature_dim"), "cnn_feature_dim": ("model", "cnn_feature_dim"),
@@ -262,6 +358,7 @@ def apply_overrides(cfg: Config, args: argparse.Namespace) -> Config:
"lr": ("train", "lr"), "lr": ("train", "lr"),
"weight_decay": ("train", "weight_decay"), "weight_decay": ("train", "weight_decay"),
"val_split": ("train", "val_split"), "val_split": ("train", "val_split"),
"val_split_mode": ("train", "val_split_mode"),
"checkpoint_dir": ("train", "checkpoint_dir"), "checkpoint_dir": ("train", "checkpoint_dir"),
"patience": ("train", "patience"), "patience": ("train", "patience"),
"num_workers": ("train", "num_workers"), "num_workers": ("train", "num_workers"),
@@ -276,5 +373,7 @@ def apply_overrides(cfg: Config, args: argparse.Namespace) -> Config:
for arg_name, (section, field) in mapping.items(): for arg_name, (section, field) in mapping.items():
val = args_dict.get(arg_name) val = args_dict.get(arg_name)
if val is not None: if val is not None:
if field == "column_weights" and isinstance(val, str):
val = [float(v) for v in val.split(",")]
setattr(getattr(cfg, section), field, val) setattr(getattr(cfg, section), field, val)
return cfg return cfg
+61 -8
View File
@@ -11,6 +11,9 @@ from torchvision import transforms
from torchvision.transforms import functional as TF from torchvision.transforms import functional as TF
from imu_norm import imu_scales from imu_norm import imu_scales
from action_norm import (normalize_action, is_range_mode,
action_bounds)
from bins import encode as bin_encode
def _natural_key(p): def _natural_key(p):
@@ -23,11 +26,23 @@ class ActionDataset(Dataset):
image_width: int = 640, image_height: int = 480, image_width: int = 640, image_height: int = 480,
normalize_actions: bool = False, normalize_actions: bool = False,
action_stats_path: str = None, episode_indices: list = None, action_stats_path: str = None, episode_indices: list = None,
frame_len: int = 3, augment: bool = False, frame_len: int = 3, sample_indices: list = None,
augment: bool = False, action_jitter: float = 0.0,
predict_delta: bool = False, rollout_len: int = 1, predict_delta: bool = False, rollout_len: int = 1,
imu_dim: int = 0): imu_dim: int = 0, normalize_mode: str = "zscore",
onehot: bool = False, onehot_sigma: float = 1.0):
self.history_len = history_len self.history_len = history_len
self.action_dim = action_dim self.action_dim = action_dim
# 固定范围类归一化(fixed_range / log_range,免 stats 文件);
# 仅 normalize_actions=True 生效
self._range_mode = (normalize_mode
if (normalize_actions and is_range_mode(normalize_mode))
else None)
# 多头 one-hot:监督目标为多组 one-hot(bins.py),历史仍走 _norm
self.onehot = onehot
self.onehot_sigma = onehot_sigma
# 动作历史角度抖动(原始空间加噪,σ ∝ 列物理范围)
self.action_jitter = action_jitter
# R2 方案一:>0 时样本带 "imu" 键(归一化姿态向量,缺失 episode 补零) # R2 方案一:>0 时样本带 "imu" 键(归一化姿态向量,缺失 episode 补零)
self.imu_dim = imu_dim self.imu_dim = imu_dim
self.image_width = image_width self.image_width = image_width
@@ -40,6 +55,10 @@ class ActionDataset(Dataset):
self.rollout_len = max(1, rollout_len) self.rollout_len = max(1, rollout_len)
self.transform = transforms.Compose([ self.transform = transforms.Compose([
# Resize 不保持宽高比(直接拉伸)是有意为之:推理端
# (predict/onnx_infer/_onnx_validate)使用完全相同的预处理,
# 训练/推理口径必须一致;改为 letterbox 会使既有 checkpoint
# 输入分布失配,如需变更须训练/推理同步修改并重训(P2-d)
transforms.Resize((image_height, image_width)), transforms.Resize((image_height, image_width)),
transforms.ToTensor(), transforms.ToTensor(),
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]),
@@ -48,6 +67,7 @@ class ActionDataset(Dataset):
episodes = ActionDataset._valid_episode_dirs(data_dir) episodes = ActionDataset._valid_episode_dirs(data_dir)
if episode_indices is not None: if episode_indices is not None:
episodes = [episodes[i] for i in episode_indices] episodes = [episodes[i] for i in episode_indices]
self._sample_indices = sample_indices
# 样本存 (image_files, actions, t_start):t_start 是 rollout 第一步的目标时刻, # 样本存 (image_files, actions, t_start):t_start 是 rollout 第一步的目标时刻,
# __getitem__ 按 rollout_len 展开 K 步。存原始数组引用避免重复加载/拷贝。 # __getitem__ 按 rollout_len 展开 K 步。存原始数组引用避免重复加载/拷贝。
@@ -78,9 +98,16 @@ class ActionDataset(Dataset):
for t_start in range(start, n - (self.rollout_len - 1)): for t_start in range(start, n - (self.rollout_len - 1)):
self.samples.append((image_files, actions, imus, t_start)) self.samples.append((image_files, actions, imus, t_start))
# 样本级随机划分:按样本索引过滤(相邻微步高度相关,随机划分
# 会让 val 略乐观——高相关样本的近邻大量在训练集,见训练报告)
if sample_indices is not None:
idx_set = set(sample_indices)
self.samples = [s for i, s in enumerate(self.samples)
if i in idx_set]
self._action_mean = None self._action_mean = None
self._action_std = None self._action_std = None
if normalize_actions: if normalize_actions and not self._range_mode:
if action_stats_path: if action_stats_path:
if not os.path.exists(action_stats_path): if not os.path.exists(action_stats_path):
raise FileNotFoundError( raise FileNotFoundError(
@@ -109,8 +136,27 @@ class ActionDataset(Dataset):
def __len__(self): def __len__(self):
return len(self.samples) return len(self.samples)
def _jit_history(self, hist_raw):
"""对原始动作历史加高斯抖动(σ = action_jitter × 列物理范围)
制造虚拟邻近状态对抗微步样本相关性;与闭环自预测历史噪声同源。
目标(真值)不抖动——监督信号保持干净。
"""
if self.action_jitter <= 0.0:
return hist_raw
lo, hi = action_bounds(self.action_dim)
sigma = self.action_jitter * (hi - lo)
noise = np.random.normal(0.0, sigma, hist_raw.shape).astype(np.float32)
return hist_raw + noise
def _norm(self, a): def _norm(self, a):
"""z-score 归一化(numpy → torch 前在 numpy 空间做,保持 float32)""" """动作归一化(numpy → torch 前在 numpy 空间做,保持 float32)
fixed_range / log_range:固定物理范围 → [0,1](clamp 防御,免 stats);
否则 z-score(_action_mean 为 None 时直通原始值)。
"""
if self._range_mode:
return normalize_action(a, self.action_dim, mode=self._range_mode)
if self._action_mean is None: if self._action_mean is None:
return a return a
return (a - self._action_mean) / self._action_std return (a - self._action_mean) / self._action_std
@@ -135,7 +181,12 @@ class ActionDataset(Dataset):
return torch.stack(frames) # (F, 3, H, W) return torch.stack(frames) # (F, 3, H, W)
def _step_target(self, actions, t): def _step_target(self, actions, t):
"""时刻 t 的监督目标(归一化空间);predict_delta 时为 Δ=actions[t]-actions[t-1]""" """时刻 t 的监督目标;onehot 时为多组 one-hot(原始空间分箱),
predict_delta 时为 Δ=actions[t]-actions[t-1](归一化空间),
否则为归一化连续值"""
if self.onehot:
return bin_encode(actions[t], self.action_dim,
sigma=self.onehot_sigma)
if self.predict_delta: if self.predict_delta:
return self._norm(actions[t]) - self._norm(actions[t - 1]) return self._norm(actions[t]) - self._norm(actions[t - 1])
return self._norm(actions[t]) return self._norm(actions[t])
@@ -177,8 +228,9 @@ class ActionDataset(Dataset):
if self.rollout_len == 1: if self.rollout_len == 1:
# 单步:与旧版输出结构完全一致(imu_dim>0 时附加 "imu" 键) # 单步:与旧版输出结构完全一致(imu_dim>0 时附加 "imu" 键)
images = self._load_frames(image_files, t_start) images = self._load_frames(image_files, t_start)
history = torch.tensor(self._norm( history = torch.tensor(self._norm(self._jit_history(
actions[t_start - self.history_len: t_start]), dtype=torch.float32) actions[t_start - self.history_len: t_start])),
dtype=torch.float32)
target = torch.tensor(self._step_target(actions, t_start), target = torch.tensor(self._step_target(actions, t_start),
dtype=torch.float32) dtype=torch.float32)
out = {"images": images, "action_history": history, "target": target} out = {"images": images, "action_history": history, "target": target}
@@ -194,7 +246,8 @@ class ActionDataset(Dataset):
"images": self._load_frames(image_files, t), "images": self._load_frames(image_files, t),
# 真实历史(scheduled sampling 的真值来源;首步必用) # 真实历史(scheduled sampling 的真值来源;首步必用)
"action_history": torch.tensor( "action_history": torch.tensor(
self._norm(actions[t - self.history_len: t]), dtype=torch.float32), self._norm(self._jit_history(
actions[t - self.history_len: t])), dtype=torch.float32),
"target": torch.tensor(self._step_target(actions, t), dtype=torch.float32), "target": torch.tensor(self._step_target(actions, t), dtype=torch.float32),
# 上一步真实动作(Δ 残差的基准 a_{t-1},归一化空间) # 上一步真实动作(Δ 残差的基准 a_{t-1},归一化空间)
"prev_action": torch.tensor(self._norm(actions[t - 1]), dtype=torch.float32), "prev_action": torch.tensor(self._norm(actions[t - 1]), dtype=torch.float32),
+27 -17
View File
@@ -51,20 +51,15 @@ def _load_episode(data_dir, ep_name):
return image_files[:n], actions[:n], imus return image_files[:n], actions[:n], imus
def _action_bounds(data_dir, action_dim, margin_ratio=0.1): def _action_bounds(action_dim):
"""从全数据集统计动作经验范围(min/max 加 margin),作为物理可行性判据。 """固定物理界作为越界判据(与 action_norm.py 常量表共用)
actpred 侧无硬限位定义(18 列混含舵机角度/频率/延时/预留),硬编码范围不可靠; 旧版用数据经验界(min/max+margin),恒值列 span≈0 时任何非零预测都
用数据分布的经验界更通用。margin 默认 10% 容忍合理外推。 算越界(unnormalized 模型 oob_rate 曾恒 100%)。"越物理界"比"越经验界"
更符合安全语义:舵机 [0,180]、spk.F [0,5000]、spk.delay [0,2000]。
""" """
mins = np.full(action_dim, np.inf, dtype=np.float32) from action_norm import action_bounds
maxs = np.full(action_dim, -np.inf, dtype=np.float32) return action_bounds(action_dim)
for ep_dir in ActionDataset._valid_episode_dirs(data_dir):
a = np.load(ep_dir / "actions.npy").astype(np.float32)
mins = np.minimum(mins, a.min(axis=0))
maxs = np.maximum(maxs, a.max(axis=0))
span = np.maximum(maxs - mins, 1e-6)
return mins - margin_ratio * span, maxs + margin_ratio * span
@np.errstate(all="ignore") @np.errstate(all="ignore")
@@ -133,7 +128,12 @@ def _smoothness(seq):
def summarize(all_steps, lo, hi): def summarize(all_steps, lo, hi):
"""汇总多个 episode 的滚动记录为报告指标""" """汇总多个 episode 的滚动记录为报告指标
error_growth 改为按 episode 归一化后取中位数:
每 episode 内部算 (末25% MSE / 首25% MSE) → 中位数聚合。
避免短 episode 尖峰被长 episode 均值淹没,也更鲁棒于长度差异。
"""
if not all_steps: if not all_steps:
raise ValueError("无可评估的滚动步(episode 太短或 horizon 过小)") raise ValueError("无可评估的滚动步(episode 太短或 horizon 过小)")
flat = [s for steps in all_steps for s in steps] flat = [s for steps in all_steps for s in steps]
@@ -147,6 +147,18 @@ def summarize(all_steps, lo, hi):
if vals: if vals:
curve.append(float(np.mean(vals))) curve.append(float(np.mean(vals)))
# 逐 episode 归一化 error_growth:每 episode 自身首末段比值 → 中位数
growths = []
for steps in all_steps:
n = len(steps)
if n < 2:
continue
m = np.array([s["mse"] for s in steps], dtype=np.float32)
front = np.mean(m[:max(1, n // 4)])
back = np.mean(m[max(0, n * 3 // 4):])
growths.append(back / max(front, 1e-12))
error_growth = float(np.median(growths)) if growths else 1.0
preds = np.array([s["pred"] for s in flat], dtype=np.float32) preds = np.array([s["pred"] for s in flat], dtype=np.float32)
trues = np.array([s["true"] for s in flat], dtype=np.float32) trues = np.array([s["true"] for s in flat], dtype=np.float32)
oob = [s.get("oob", False) for s in flat] oob = [s.get("oob", False) for s in flat]
@@ -156,9 +168,7 @@ def summarize(all_steps, lo, hi):
"n_steps": len(flat), "n_steps": len(flat),
"overall_mse": float(np.mean(mses)), "overall_mse": float(np.mean(mses)),
# 末段(后 25% 步)相对首段(前 25%)的误差比,>1 表示误差随步数发散 # 末段(后 25% 步)相对首段(前 25%)的误差比,>1 表示误差随步数发散
"error_growth": float(np.mean(mses[int(len(mses) * 0.75):]) / "error_growth": error_growth,
max(np.mean(mses[:max(1, int(len(mses) * 0.25))]),
1e-12)),
"error_curve": curve, "error_curve": curve,
"pred_smoothness": _smoothness(preds), "pred_smoothness": _smoothness(preds),
"true_smoothness": _smoothness(trues), "true_smoothness": _smoothness(trues),
@@ -173,7 +183,7 @@ def evaluate(checkpoint, data_dir, episodes=None, horizon=None, device=None):
ep_names = _episode_ids(data_dir) ep_names = _episode_ids(data_dir)
if episodes is not None: if episodes is not None:
ep_names = ep_names[-episodes:] # 取尾部 episode 作为留出集 ep_names = ep_names[-episodes:] # 取尾部 episode 作为留出集
lo, hi = _action_bounds(data_dir, predictor.action_dim) lo, hi = _action_bounds(predictor.action_dim)
all_steps = [] all_steps = []
for name in ep_names: for name in ep_names:
image_files, actions, imus = _load_episode(data_dir, name) image_files, actions, imus = _load_episode(data_dir, name)
+37 -13
View File
@@ -13,6 +13,7 @@ Reshape 序列。此脚本逐项改造(权重数值完全不变):
import argparse import argparse
import os import os
import sys
import numpy as np import numpy as np
import torch import torch
@@ -21,6 +22,10 @@ import torch.nn as nn
from config import parse_checkpoint_config from config import parse_checkpoint_config
from model import ActionPredictor from model import ActionPredictor
# 一致性校验阈值:wrapper 改造/ORT 转换与原模型输出差异超过即拦截退出,
# 防止 ManualAttnBlock 依赖的 PyTorch 布局约定变更静默穿透(v3-P2-2)
MAX_TOLERATED_DIFF = 1e-4
IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32) IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
IMAGENET_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32) IMAGENET_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32)
@@ -132,11 +137,13 @@ class DeployWrapper(nn.Module):
x = self.proj_convs[i](x) # 1x1 conv ≡ 池化后的 img_proj x = self.proj_convs[i](x) # 1x1 conv ≡ 池化后的 img_proj
return self.pool(x) # (1, d, 1, 1) NCHW return self.pool(x) # (1, d, 1, 1) NCHW
def forward(self, frame0: torch.Tensor, frame1: torch.Tensor, frame2: torch.Tensor, def forward(self, *inputs: torch.Tensor) -> torch.Tensor:
action_history: torch.Tensor, pred_token: torch.Tensor, # inputs = frame0..frame{F-1}, action_history, pred_token, [imu]
imu: torch.Tensor = None) -> torch.Tensor:
# 全程 (1, 1, S, d) 布局:seq 占 W 维,避免任何 transpose # 全程 (1, 1, S, d) 布局:seq 占 W 维,避免任何 transpose
frames = [frame0, frame1, frame2] frames = list(inputs[:self._frame_len])
action_history = inputs[self._frame_len]
pred_token = inputs[self._frame_len + 1]
imu = inputs[self._frame_len + 2] if self._imu_dim > 0 else None
feats = [self._frame_feat(frames[i], i).permute(0, 2, 3, 1) feats = [self._frame_feat(frames[i], i).permute(0, 2, 3, 1)
for i in range(self._frame_len)] # (1, 1, 1, d) NHWC for i in range(self._frame_len)] # (1, 1, 1, d) NHWC
img_tokens = torch.cat(feats, dim=2) + self.proj_bias # (1, 1, F, d) img_tokens = torch.cat(feats, dim=2) + self.proj_bias # (1, 1, F, d)
@@ -156,7 +163,8 @@ class DeployWrapper(nn.Module):
def check_token_ranges(wrapper: "DeployWrapper", frame_len: int, height: int, def check_token_ranges(wrapper: "DeployWrapper", frame_len: int, height: int,
width: int, history_len: int, action_dim: int, width: int, history_len: int, action_dim: int,
pred_token: torch.Tensor, max_abs: float = MAX_TOKEN_ABS, pred_token: torch.Tensor, max_abs: float = MAX_TOKEN_ABS,
action_stats_path: str = None): action_stats_path: str = None,
normalize_mode: str = "zscore"):
"""导出前检查 token 量级:幅度过大时 uint8 量化会把小信号压成 0 """导出前检查 token 量级:幅度过大时 uint8 量化会把小信号压成 0
预训练用全零图像占位 → BN running_var 停在初始小值 → 推理时每级 预训练用全零图像占位 → BN running_var 停在初始小值 → 推理时每级
@@ -164,21 +172,26 @@ def check_token_ranges(wrapper: "DeployWrapper", frame_len: int, height: int,
幅度分配共享量化 scale(实测 scale=1965.92,pred_token/act_tokens 幅度分配共享量化 scale(实测 scale=1965.92,pred_token/act_tokens
全部量化成 0),HEF 输出对任何输入恒定。必须在编译前拦截。 全部量化成 0),HEF 输出对任何输入恒定。必须在编译前拦截。
归一化模型(normalize_actions)的 HEF 输入是 z-score 后的历史, 归一化模型(normalize_actions)的 HEF 输入是归一化后的历史,
检查输入须与推理端一致。 检查输入须与推理端一致(zscore → z 空间采样;fixed_range /
log_range → [0,1])。
""" """
rng = np.random.default_rng(42) rng = np.random.default_rng(42)
raw = rng.integers(0, 256, size=(height, width, 3), dtype=np.uint8) raw = rng.integers(0, 256, size=(height, width, 3), dtype=np.uint8)
norm = (raw.astype(np.float32) / 255.0 - IMAGENET_MEAN) / IMAGENET_STD norm = (raw.astype(np.float32) / 255.0 - IMAGENET_MEAN) / IMAGENET_STD
frames = [torch.tensor(norm[None]) for _ in range(frame_len)] frames = [torch.tensor(norm[None]) for _ in range(frame_len)]
history = rng.uniform(0.0, 500.0, size=(1, 1, history_len, action_dim)) history = rng.uniform(0.0, 500.0, size=(1, 1, history_len, action_dim))
if action_stats_path is not None: from action_norm import is_range_mode
if is_range_mode(normalize_mode):
# fixed_range / log_range 归一化空间即 [0,1],直接在归一化空间采样
history = rng.uniform(0.0, 1.0, size=(1, 1, history_len, action_dim))
elif action_stats_path is not None:
stats = np.load(action_stats_path) stats = np.load(action_stats_path)
if stats.shape != (2, action_dim): if stats.shape != (2, action_dim):
raise ValueError( raise ValueError(
f"action stats 形状应为 (2, {action_dim})," f"action stats 形状应为 (2, {action_dim}),"
f"实际为 {stats.shape}: {action_stats_path}") f"实际为 {stats.shape}: {action_stats_path}")
# 归一化模型直接在 z 空间采样:raw 均匀采样会在零方差列上 # z-score 归一化模型直接在 z 空间采样:raw 均匀采样会在零方差列上
# 产生天文数字 z 值(实际推理中这些列恒为 0,z 恒为 0) # 产生天文数字 z 值(实际推理中这些列恒为 0,z 恒为 0)
history = rng.normal(0.0, 1.5, size=(1, 1, history_len, action_dim)) history = rng.normal(0.0, 1.5, size=(1, 1, history_len, action_dim))
history = torch.tensor(history, dtype=torch.float32) history = torch.tensor(history, dtype=torch.float32)
@@ -224,8 +237,10 @@ def main():
h, w = cfg.data.image_height, cfg.data.image_width h, w = cfg.data.image_height, cfg.data.image_width
f = cfg.data.frame_len f = cfg.data.frame_len
stats_path = None stats_path = None
if cfg.data.normalize_actions: from action_norm import is_range_mode
if cfg.data.normalize_actions and not is_range_mode(cfg.data.normalize_mode):
# 与 predict.py 语义一致:相对路径按 checkpoint 目录解析 # 与 predict.py 语义一致:相对路径按 checkpoint 目录解析
# (fixed_range / log_range 模式免 stats 文件,跳过)
stats_path = cfg.data.action_stats_path stats_path = cfg.data.action_stats_path
if not stats_path: if not stats_path:
stats_path = os.path.join( stats_path = os.path.join(
@@ -239,7 +254,8 @@ def main():
f"模型训练时启用了动作归一化,但未找到统计文件: {stats_path}") f"模型训练时启用了动作归一化,但未找到统计文件: {stats_path}")
check_token_ranges(wrapper, f, h, w, cfg.data.history_len, check_token_ranges(wrapper, f, h, w, cfg.data.history_len,
cfg.data.action_dim, model.pred_token.data.unsqueeze(1), cfg.data.action_dim, model.pred_token.data.unsqueeze(1),
action_stats_path=stats_path) action_stats_path=stats_path,
normalize_mode=cfg.data.normalize_mode)
dummy_frames = [torch.randn(1, h, w, 3) for _ in range(f)] dummy_frames = [torch.randn(1, h, w, 3) for _ in range(f)]
dummy_history = torch.randn(1, 1, cfg.data.history_len, cfg.data.action_dim) dummy_history = torch.randn(1, 1, cfg.data.history_len, cfg.data.action_dim)
dummy_pred = model.pred_token.data.unsqueeze(1).clone() # (1, 1, 1, d) dummy_pred = model.pred_token.data.unsqueeze(1).clone() # (1, 1, 1, d)
@@ -272,7 +288,11 @@ def main():
with torch.no_grad(): with torch.no_grad():
orig_out = model(stacked, dummy_history.squeeze(1), orig_out = model(stacked, dummy_history.squeeze(1),
dummy_imu.squeeze(1).squeeze(1) if imu_dim > 0 else None) dummy_imu.squeeze(1).squeeze(1) if imu_dim > 0 else None)
print(f"wrapper 与原模型最大差异: {(ref.squeeze(1) - orig_out).abs().max():.6e}") wrap_diff = (ref.squeeze(1) - orig_out).abs().max().item()
print(f"wrapper 与原模型最大差异: {wrap_diff:.6e}")
if wrap_diff > MAX_TOLERATED_DIFF:
sys.exit(f"错误: wrapper 与原模型差异 {wrap_diff:.3e} > "
f"{MAX_TOLERATED_DIFF:.0e}(ManualAttnBlock 布局约定可能已失效)")
try: try:
import onnxruntime as ort import onnxruntime as ort
@@ -284,7 +304,11 @@ def main():
if imu_dim > 0: if imu_dim > 0:
feeds["imu"] = dummy_imu.numpy() feeds["imu"] = dummy_imu.numpy()
ort_out = sess.run(None, feeds)[0] ort_out = sess.run(None, feeds)[0]
print(f"ONNX 一致性验证通过(最大差异: {np.abs(ort_out - ref.numpy()).max():.6e})") ort_diff = np.abs(ort_out - ref.numpy()).max()
if ort_diff > MAX_TOLERATED_DIFF:
sys.exit(f"错误: ONNX 与 wrapper 差异 {ort_diff:.3e} > "
f"{MAX_TOLERATED_DIFF:.0e}")
print(f"ONNX 一致性验证通过(最大差异: {ort_diff:.6e})")
except ImportError: except ImportError:
pass pass
+27
View File
@@ -1,9 +1,14 @@
import argparse import argparse
import sys
import torch import torch
from torch.export import Dim from torch.export import Dim
from config import parse_checkpoint_config from config import parse_checkpoint_config
from model import ActionPredictor from model import ActionPredictor
# 一致性校验阈值:ORT 转换与原模型输出差异超过即拦截退出(v3-P2-2)
MAX_TOLERATED_DIFF = 1e-4
def main(): def main():
parser = argparse.ArgumentParser(description="Export actpred model to ONNX") parser = argparse.ArgumentParser(description="Export actpred model to ONNX")
@@ -43,6 +48,25 @@ def main():
print(f"ONNX model exported to: {args.output}" print(f"ONNX model exported to: {args.output}"
f"{'(含 imu 输入)' if imu_dim > 0 else ''}") f"{'(含 imu 输入)' if imu_dim > 0 else ''}")
# 写入训练配置元数据:onnx_infer 据此自动识别归一化方式与残差模式,
# 避免推理端配置与训练不一致导致静默错误(normalize_actions=False 时
# 记为 "none",推理端不做任何归一化)
import onnx
m = onnx.load(args.output)
meta = {
"normalize_mode": (data_cfg.normalize_mode
if data_cfg.normalize_actions else "none"),
"predict_delta": ("1" if getattr(cfg.train, "predict_delta", False)
else "0"),
"imu_dim": str(imu_dim),
"action_dim": str(data_cfg.action_dim),
}
for k, v in meta.items():
p = m.metadata_props.add()
p.key, p.value = k, v
onnx.save(m, args.output)
print(f"ONNX 元数据已写入: {meta}")
# 可选:验证导出的 ONNX 模型一致性 # 可选:验证导出的 ONNX 模型一致性
try: try:
import onnxruntime as ort import onnxruntime as ort
@@ -56,6 +80,9 @@ def main():
with torch.no_grad(): with torch.no_grad():
torch_out = model(dummy_images, dummy_history, *extra_inputs).numpy() torch_out = model(dummy_images, dummy_history, *extra_inputs).numpy()
max_diff = np.abs(ort_out - torch_out).max() max_diff = np.abs(ort_out - torch_out).max()
if max_diff > MAX_TOLERATED_DIFF:
sys.exit(f"错误: ONNX 与 PyTorch 输出差异 {max_diff:.3e} > "
f"{MAX_TOLERATED_DIFF:.0e}")
print(f"ONNX 一致性验证通过(最大差异: {max_diff:.6e})") print(f"ONNX 一致性验证通过(最大差异: {max_diff:.6e})")
except ImportError: except ImportError:
print("提示: 安装 onnxruntime 可验证导出模型一致性 (pip install onnxruntime)") print("提示: 安装 onnxruntime 可验证导出模型一致性 (pip install onnxruntime)")
+10 -2
View File
@@ -86,6 +86,7 @@ def build_image_samples(n_samples, frame_len, height, width, rng,
def build_deploy_set(args): def build_deploy_set(args):
import torch import torch
from action_norm import is_range_mode, normalize_action
from model import ActionPredictor from model import ActionPredictor
ckpt = torch.load(args.model, map_location="cpu", weights_only=True) ckpt = torch.load(args.model, map_location="cpu", weights_only=True)
@@ -101,8 +102,15 @@ def build_deploy_set(args):
history = build_action_history_samples( history = build_action_history_samples(
args.mt_dir, args.samples, cfg.history_len, cfg.action_dim, rng) args.mt_dir, args.samples, cfg.history_len, cfg.action_dim, rng)
history = history[:, None, :, :] # (N, 1, H_len, D) history = history[:, None, :, :] # (N, 1, H_len, D)
if cfg.normalize_actions: if cfg.normalize_actions and is_range_mode(cfg.normalize_mode):
# 归一化模型的 HEF 输入是 z-score 历史(hailo_infer --stats 同语义), # 固定范围类归一化:校准数据与推理端同一变换(免 stats 文件)
from action_norm import normalize_action, is_range_mode
history = normalize_action(history, cfg.action_dim,
mode=cfg.normalize_mode)
print(f" action_history: 已 {cfg.normalize_mode} 归一化"
f"(固定物理范围)")
elif cfg.normalize_actions:
# z-score 归一化模型的 HEF 输入是 z-score 历史(hailo_infer --stats 同语义),
# 校准数据必须走同一变换,否则量化范围与推理分布不匹配 # 校准数据必须走同一变换,否则量化范围与推理分布不匹配
stats_path = cfg.action_stats_path stats_path = cfg.action_stats_path
if not stats_path: if not stats_path:
+292 -47
View File
@@ -31,29 +31,51 @@ import contextlib
import json import json
import os import os
import re import re
import threading
import time
import numpy as np import numpy as np
from PIL import Image from PIL import Image
try:
import cv2 # 可选:帧归一化+量化合并查表(加速预处理)
HAS_CV2 = True
except ImportError:
cv2 = None
HAS_CV2 = False
from imu_norm import normalize_imu from imu_norm import normalize_imu
from action_norm import (normalize_action, denormalize_action,
is_range_mode)
from utils import parse_action_history
from bins import TOTAL_BINS, decode as bin_decode, fixed_zero_columns
IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32) IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
IMAGENET_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32) IMAGENET_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32)
# 与 predict.parse_action_history 相同逻辑;此处内联以保持 # 异步推理缓冲数:≥4 时流水线吞吐趋于饱和(实测 4 缓冲 ~2.7×、6 缓冲 ~2.9×)
# 本模块零 torch 依赖(HAT 部署环境只装 hailo_platform + numpy + PIL) _ASYNC_BUFFERS = 4
def parse_action_history(s: str, action_dim: int) -> np.ndarray: class _AsyncJob:
steps = s.split(":") """predict_async 的推理句柄:result() 阻塞返回 (action_dim,) 动作向量"""
history = []
for step in steps: def __init__(self):
values = [float(v) for v in step.split(",")] self._done = threading.Event()
if len(values) != action_dim: self._result = None
raise ValueError( self._error = None
f"Expected {action_dim} values per step, got {len(values)}")
history.append(values) def result(self, timeout: float = None) -> np.ndarray:
return np.array(history, dtype=np.float32) if not self._done.wait(timeout):
raise TimeoutError("async HEF inference timeout")
if self._error is not None:
raise self._error
return self._result
def _complete(self, result=None, error=None):
self._result = result
self._error = error
self._done.set()
def _input_layer_index(name: str) -> int: def _input_layer_index(name: str) -> int:
@@ -72,9 +94,39 @@ class HailoPredictor:
action = predictor.predict(images, action_history) action = predictor.predict(images, action_history)
""" """
def __init__(self, hef_path: str, stats_path: str = None, vdevice=None): def __init__(self, hef_path: str, stats_path: str = None, vdevice=None,
predict_delta: bool = False, pred_token=None,
normalize_mode: str = "zscore", column_weights=None):
"""HEF 推理器。
predict_delta: 模型用 --predict-delta 训练时须置 True,推理输出
(归一化空间)会加回 history 末步再反归一化,与 predict.py 对齐。
pred_token: np.ndarray 或 .npy 路径;export_deploy.py --pred-token-out
产物。缺省时喂零(仅适合 pred_token 恰好全零的模型)。
normalize_mode: 与训练时 DataConfig.normalize_mode 一致。
"zscore"(默认)需传 stats_path;"fixed_range" / "log_range"
免 stats 文件,按固定物理范围归一化(action_norm.py)。
column_weights: 逐列损失权重(onehot HEF 的权重 0 列按
bins.FIXED_OUTPUT 固定,防闭环垃圾回写);None 时用
action_norm.default_column_weights。
"""
from hailo_platform import HEF, VDevice from hailo_platform import HEF, VDevice
self._predict_delta = predict_delta
self._range_mode = (normalize_mode
if is_range_mode(normalize_mode) else None)
self._column_weights = None
if column_weights is not None:
self._column_weights = np.asarray(column_weights,
dtype=np.float32)
self._default_pred_token = None
if pred_token is not None:
if isinstance(pred_token, str):
self._default_pred_token = np.load(pred_token)
else:
self._default_pred_token = np.asarray(pred_token,
dtype=np.float32)
self._stack = contextlib.ExitStack() self._stack = contextlib.ExitStack()
if vdevice is None: if vdevice is None:
vdevice = self._stack.enter_context(VDevice()) vdevice = self._stack.enter_context(VDevice())
@@ -96,7 +148,7 @@ class HailoPredictor:
self._action_mean = None self._action_mean = None
self._action_std = None self._action_std = None
if stats_path is not None: if stats_path is not None and not self._range_mode:
if not os.path.exists(stats_path): if not os.path.exists(stats_path):
raise FileNotFoundError(f"统计文件不存在: {stats_path}") raise FileNotFoundError(f"统计文件不存在: {stats_path}")
stats = np.load(stats_path) stats = np.load(stats_path)
@@ -121,7 +173,6 @@ class HailoPredictor:
imu 为 R2 方案一新增输入(1,1,1,imu_dim),旧 HEF 无此输入。 imu 为 R2 方案一新增输入(1,1,1,imu_dim),旧 HEF 无此输入。
""" """
ins = list(self._in_infos) ins = list(self._in_infos)
out_dim = tuple(self._out_info.shape)[-1]
# 新版:每帧独立 (H,W,3) 输入 # 新版:每帧独立 (H,W,3) 输入
frames = [i for i in ins if len(i.shape) == 3 frames = [i for i in ins if len(i.shape) == 3
and i.shape[2] == 3 and i.shape[0] > 3] and i.shape[2] == 3 and i.shape[0] > 3]
@@ -134,10 +185,10 @@ class HailoPredictor:
history, pred, imu = None, None, None history, pred, imu = None, None, None
for i in rest: for i in rest:
s = tuple(i.shape) s = tuple(i.shape)
if len(s) == 3 and s[-1] == out_dim: if len(s) == 3 and s[1] == 1:
history = i pred = i # pred_token (1,1,d)
elif len(s) == 3 and s[1] == 1: elif len(s) == 3:
pred = i history = i # 动作历史 (1,H_len,D)
elif len(s) == 4 and s[0] == 1 and s[1] == 1 and s[2] == 1: elif len(s) == 4 and s[0] == 1 and s[1] == 1 and s[2] == 1:
imu = i # R2:imu (1,1,1,imu_dim) imu = i # R2:imu (1,1,1,imu_dim)
else: else:
@@ -148,7 +199,8 @@ class HailoPredictor:
raise ValueError("HEF 输入中未找到图像输入") raise ValueError("HEF 输入中未找到图像输入")
if history is None: if history is None:
raise ValueError( raise ValueError(
f"HEF 输入中未找到动作历史输入(输出维度 {out_dim})," f"HEF 输入中未找到动作历史输入(输出维度 "
f"{tuple(self._out_info.shape)}),"
f"实际: {[(i.name, tuple(i.shape)) for i in ins]}") f"实际: {[(i.name, tuple(i.shape)) for i in ins]}")
return frames, history, pred, imu return frames, history, pred, imu
@@ -188,6 +240,20 @@ class HailoPredictor:
self._normalize_frames = \ self._normalize_frames = \
(0 - self._frame_quant.qp_zp) * self._frame_quant.qp_scale < -0.5 (0 - self._frame_quant.qp_zp) * self._frame_quant.qp_scale < -0.5
# cv2.LUT 查表(归一化+量化合并一步):仅逐帧独立输入布局且
# 帧边缘为 uint8 时可用;无 cv2 或旧布局回退 float 路径
from hailo_platform import FormatType
self._frame_luts = None
if HAS_CV2 and self._image_layout == "nhwc_frames" and \
all(f.format.type == FormatType.UINT8
for f in self._frame_infos):
self._frame_luts = self._build_frame_luts()
# 异步推理缓冲池(predict_async 复用)
self._async_buffers = [self._configured.create_bindings()
for _ in range(_ASYNC_BUFFERS)]
self._async_flags = [[True] for _ in range(_ASYNC_BUFFERS)]
h_shape = tuple(self._history_info.shape) h_shape = tuple(self._history_info.shape)
self.history_len, self.action_dim = h_shape[1], h_shape[2] self.history_len, self.action_dim = h_shape[1], h_shape[2]
@@ -198,9 +264,16 @@ class HailoPredictor:
out_shape = tuple(self._out_info.shape) out_shape = tuple(self._out_info.shape)
out_dim = out_shape[-1] out_dim = out_shape[-1]
if out_dim != self.action_dim: if out_dim == TOTAL_BINS:
raise ValueError( # onehot 模型:输出为多头 logits,predict 时经 bins.decode
f"输出动作维度 {out_dim} 与动作历史维度 {self.action_dim} 不一致") # 解码为 action_dim 原始量纲(无需反归一化)
self._onehot = True
else:
self._onehot = False
if out_dim != self.action_dim:
raise ValueError(
f"输出动作维度 {out_dim} 与动作历史维度 "
f"{self.action_dim} 不一致")
# ---------------- 生命周期 ---------------- # ---------------- 生命周期 ----------------
@@ -216,16 +289,8 @@ class HailoPredictor:
# ---------------- 推理 ---------------- # ---------------- 推理 ----------------
def predict(self, images, action_history: np.ndarray, def _prepare_inputs(self, images, action_history):
pred_token: np.ndarray = None, """输入校验 + 动作历史归一化(predict/predict_async 共用)"""
imu: np.ndarray = None) -> np.ndarray:
"""images: 长度为 frame_len 的 RGB 图像列表(旧→新,末帧为当前帧)
imu: 可选原始姿态向量(roll,pitch,ax,ay,az,gx,gy,gz,HEF 含 imu
输入时生效,内部按物理量程归一化;None 等价零向量)
返回 (action_dim,) float32 动作向量。pred_token 仅新版 5 输入 HEF
需要;默认喂零,抖动校准的新 HEF 建议喂 checkpoint 学习值。
"""
if not isinstance(images, (list, tuple)): if not isinstance(images, (list, tuple)):
images = [images] images = [images]
if len(images) != self.frame_len: if len(images) != self.frame_len:
@@ -238,14 +303,26 @@ class HailoPredictor:
raise ValueError( raise ValueError(
f"action_history 形状应为 {expected},实际为 {action_history.shape}") f"action_history 形状应为 {expected},实际为 {action_history.shape}")
if self._action_mean is not None: if self._range_mode:
action_history = normalize_action(action_history, self.action_dim,
mode=self._range_mode)
elif self._action_mean is not None:
action_history = (action_history - self._action_mean) / self._action_std action_history = (action_history - self._action_mean) / self._action_std
return images, action_history
def _fill_bindings(self, bindings, images, action_history,
pred_token=None, imu=None):
"""按 HEF 布局把(已归一化的)输入填充到 bindings 缓冲"""
from hailo_platform import FormatType from hailo_platform import FormatType
bindings = self._configured.create_bindings()
frame_float = np.stack([self._frame_float(img) for img in images]) if self._frame_luts is not None:
self._bind_frames(bindings, frame_float) # cv2.LUT 快路径:逐帧独立输入,直接绑定量化缓冲
for i, (info, img) in enumerate(zip(self._frame_infos, images)):
buf = self._frame_lut_quant(img, i)
bindings.input(info.name).set_buffer(buf)
else:
frame_float = np.stack([self._frame_float(img) for img in images])
self._bind_frames(bindings, frame_float)
h_shape = tuple(self._history_info.shape) h_shape = tuple(self._history_info.shape)
if self._history_info.format.type == FormatType.UINT8: if self._history_info.format.type == FormatType.UINT8:
@@ -258,6 +335,8 @@ class HailoPredictor:
if self._pred_info is not None: if self._pred_info is not None:
p_shape = tuple(self._pred_info.shape) p_shape = tuple(self._pred_info.shape)
if pred_token is None:
pred_token = self._default_pred_token
if pred_token is None: if pred_token is None:
values = np.zeros(p_shape, dtype=np.float32) values = np.zeros(p_shape, dtype=np.float32)
else: else:
@@ -285,19 +364,120 @@ class HailoPredictor:
out_buf = np.zeros(out_shape, dtype=np.float32) out_buf = np.zeros(out_shape, dtype=np.float32)
bindings.output(self._out_info.name).set_buffer(out_buf) bindings.output(self._out_info.name).set_buffer(out_buf)
self._configured.run([bindings], timeout=10000) def _postprocess(self, raw_result, history_tail=None) -> np.ndarray:
"""反量化 + 解码/反归一化 → (action_dim,)(predict/predict_async 共用)
result = np.asarray( history_tail: 归一化空间的历史末步(predict_delta 残差模式用)。
bindings.output(self._out_info.name).get_buffer(), """
dtype=np.float32) from hailo_platform import FormatType
if self._out_info.format.type == FormatType.UINT8: if self._out_info.format.type == FormatType.UINT8:
q = self._out_info.quant_info q = self._out_info.quant_info
result = (result - q.qp_zp) * q.qp_scale raw_result = (raw_result - q.qp_zp) * q.qp_scale
result = result.reshape(-1) result = raw_result.reshape(-1)
if self._action_mean is not None: if self._onehot:
result = result * self._action_std + self._action_mean # 多头 logits → 期望值解码 → 原始量纲(箱生活在原始空间,
# 无需反归一化);权重 0 列固定为常量(与 predict.py 对齐),
# 防闭环垃圾回写
result = bin_decode(result, self.action_dim).reshape(-1)
if self._column_weights is None:
from action_norm import default_column_weights
self._column_weights = default_column_weights(
self.action_dim)
w = np.asarray(self._column_weights, dtype=np.float32)
fixed = fixed_zero_columns(w, self.action_dim)
result[w[:self.action_dim] <= 0.0] = (
fixed[w[:self.action_dim] <= 0.0])
else:
if self._predict_delta:
# 残差模式:Δ̂ + a_{t-1}(归一化空间),与 predict.py 对齐
result = result + history_tail
if self._range_mode:
result = denormalize_action(result, self.action_dim,
mode=self._range_mode)
elif self._action_mean is not None:
result = result * self._action_std + self._action_mean
if not np.all(np.isfinite(result)):
print("WARNING: HEF output contains NaN/Inf, replacing with zeros")
result = np.nan_to_num(result, nan=0.0, posinf=0.0, neginf=0.0)
return result return result
def predict(self, images, action_history: np.ndarray,
pred_token: np.ndarray = None,
imu: np.ndarray = None) -> np.ndarray:
"""images: 长度为 frame_len 的 RGB 图像列表(旧→新,末帧为当前帧)
imu: 可选原始姿态向量(roll,pitch,ax,ay,az,gx,gy,gz,HEF 含 imu
输入时生效,内部按物理量程归一化;None 等价零向量)
返回 (action_dim,) float32 动作向量。pred_token 仅新版 5 输入 HEF
需要;默认喂零,抖动校准的新 HEF 建议喂 checkpoint 学习值。
"""
images, action_history = self._prepare_inputs(images, action_history)
bindings = self._configured.create_bindings()
self._fill_bindings(bindings, images, action_history,
pred_token=pred_token, imu=imu)
self._configured.run([bindings], timeout=10000)
raw = np.asarray(
bindings.output(self._out_info.name).get_buffer(),
dtype=np.float32)
return self._postprocess(raw, action_history[-1])
# ---------------- 异步推理 ----------------
def predict_async(self, images, action_history: np.ndarray,
pred_token: np.ndarray = None,
imu: np.ndarray = None) -> "_AsyncJob":
"""提交异步推理(run_async 流水线,多缓冲复用)
与 predict() 语义/输出完全一致,但立即返回 job;调用方可在
NPU 推理期间并行做其他工作(如抓帧/执行上一步动作),需要结果
时 job.result() 阻塞获取。连续提交时(≥2 个在途)NPU 保持满负荷,
实测吞吐可达同步 predict 的 ~2.9×。
注意:绑定缓冲有限(_ASYNC_BUFFERS 个),在途任务超过缓冲数时
本调用会阻塞等待空闲缓冲;job.result(timeout) 超时不会释放缓冲,
缓冲由完成回调释放。
"""
images, action_history = self._prepare_inputs(images, action_history)
history_tail = action_history[-1]
idx = self._acquire_async_buffer()
bindings = self._async_buffers[idx]
job = _AsyncJob()
try:
self._fill_bindings(bindings, images, action_history,
pred_token=pred_token, imu=imu)
except Exception:
self._release_async_buffer(idx)
raise
def cb(completion_info):
try:
exc = getattr(completion_info, "exception", None)
if exc:
raise exc
raw = np.asarray(
bindings.output(self._out_info.name).get_buffer(),
dtype=np.float32)
job._complete(result=self._postprocess(raw, history_tail))
except Exception as e: # noqa: BLE001 —— 回调内兜底
job._complete(error=e)
finally:
self._release_async_buffer(idx)
self._configured.run_async([bindings], callback=cb)
return job
def _acquire_async_buffer(self) -> int:
"""取一个空闲绑定缓冲(在途任务超限时阻塞等待)"""
while True:
for i in range(len(self._async_buffers)):
if self._async_flags[i][0]:
self._async_flags[i][0] = False
return i
time.sleep(0.0001)
def _release_async_buffer(self, idx: int):
self._async_flags[idx][0] = True
def _frame_float(self, image) -> np.ndarray: def _frame_float(self, image) -> np.ndarray:
"""Resize → float32 HWC;归一化 HEF(负零偏)再走 ImageNet 归一化""" """Resize → float32 HWC;归一化 HEF(负零偏)再走 ImageNet 归一化"""
img = Image.fromarray(image) if isinstance(image, np.ndarray) else image img = Image.fromarray(image) if isinstance(image, np.ndarray) else image
@@ -308,6 +488,43 @@ class HailoPredictor:
x = (x / 255.0 - IMAGENET_MEAN) / IMAGENET_STD x = (x / 255.0 - IMAGENET_MEAN) / IMAGENET_STD
return x return x
def _build_frame_luts(self) -> list:
"""按帧 quant_info 构建 256 项单通道查表(归一化+量化合并一步)
OpenCV 5.0 的 cv2.LUT 只接受单通道表,故每帧返回 [R,G,B] 三张表。
数学上与 float 路径(_frame_float + quantize)等价(实测 0 LSB)。
"""
luts = []
for info in self._frame_infos:
q = info.quant_info
c = np.arange(256, dtype=np.float32)[:, None]
if self._normalize_frames:
v = (c / 255.0 - IMAGENET_MEAN[None, :]) / IMAGENET_STD[None, :]
else:
v = np.broadcast_to(c, (256, 3))
v = v / q.qp_scale + q.qp_zp
lut = np.clip(np.round(v), 0, 255).astype(np.uint8) # (256,3)
luts.append([lut[:, ch] for ch in range(3)])
return luts
def _frame_lut_quant(self, image, frame_idx: int = 0) -> np.ndarray:
"""cv2.LUT 直通:原始 uint8 像素 → 量化 uint8 缓冲(免 float 中间态)
非 uint8 / 非 3 通道 / 尺寸不匹配输入走与 _frame_float 相同的 PIL
转换,保证两条路径语义一致(RGBA 经 convert 合成、灰度转 RGB)。
"""
x = np.asarray(image)
if x.dtype != np.uint8 or x.ndim != 3 or x.shape[2] != 3 or \
x.shape[:2] != (self.image_height, self.image_width):
img = Image.fromarray(x).convert("RGB")
if img.size != (self.image_width, self.image_height):
img = img.resize((self.image_width, self.image_height),
Image.BILINEAR)
x = np.asarray(img, dtype=np.uint8)
luts = self._frame_luts[frame_idx]
outs = [cv2.LUT(x[..., ch], lut) for ch, lut in enumerate(luts)]
return np.ascontiguousarray(cv2.merge(outs))
def _bind_frames(self, bindings, frame_float: np.ndarray): def _bind_frames(self, bindings, frame_float: np.ndarray):
"""(F,H,W,3) float 帧 → HEF 帧输入缓冲区(按布局与量化)""" """(F,H,W,3) float 帧 → HEF 帧输入缓冲区(按布局与量化)"""
from hailo_platform import FormatType from hailo_platform import FormatType
@@ -345,10 +562,17 @@ def main():
parser.add_argument("--action-history", required=True, parser.add_argument("--action-history", required=True,
help="冒号分隔各步,每步 action_dim 个值") help="冒号分隔各步,每步 action_dim 个值")
parser.add_argument("--stats", default=None, parser.add_argument("--stats", default=None,
help="action_stats.npy 路径(训练时启用归一化才需要)") help="action_stats.npy 路径(zscore 归一化训练才需要)")
parser.add_argument("--normalize-mode", choices=["zscore", "fixed_range"],
default=None,
help="与训练时 normalize_mode 一致;fixed_range 免 --stats。"
"缺省按 --ckpt 配置自动判定,无 --ckpt 时为 zscore")
parser.add_argument("--pred-token", default=None, parser.add_argument("--pred-token", default=None,
help="pred_token .npy 路径(export_deploy.py " help="pred_token .npy 路径(export_deploy.py "
"--pred-token-out 产物;缺省喂零)") "--pred-token-out 产物;缺省喂零)")
parser.add_argument("--ckpt", default=None,
help="checkpoint .pt 路径(自动检测 predict_delta;"
"HEF 部署时需要以恢复训练配置)")
parser.add_argument("--imu", type=str, default=None, parser.add_argument("--imu", type=str, default=None,
help="可选姿态向量(逗号分隔,原始量纲:roll,pitch 度 / " help="可选姿态向量(逗号分隔,原始量纲:roll,pitch 度 / "
"ax..gz int16 原始值);HEF 含 imu 输入且缺省时按零向量") "ax..gz int16 原始值);HEF 含 imu 输入且缺省时按零向量")
@@ -361,7 +585,28 @@ def main():
if args.imu is not None: if args.imu is not None:
imu = np.array([float(v) for v in args.imu.split(",")], imu = np.array([float(v) for v in args.imu.split(",")],
dtype=np.float32) dtype=np.float32)
with HailoPredictor(args.hef, stats_path=args.stats) as predictor:
predict_delta = False
normalize_mode = args.normalize_mode or "zscore"
if args.ckpt:
import torch
ck = torch.load(args.ckpt, map_location="cpu", weights_only=True)
cfg = ck.get("config", {})
predict_delta = cfg.get("train", {}).get("predict_delta", False)
if predict_delta:
print("predict_delta=True: HEF 输出将加回历史末步(残差模式)")
if args.normalize_mode is None:
# 未显式指定时按 checkpoint 配置判定归一化方式(旧 checkpoint 无
# normalize_mode 键,默认 zscore,行为不变)
data_cfg = cfg.get("data", {})
if (data_cfg.get("normalize_actions")
and data_cfg.get("normalize_mode") == "fixed_range"):
normalize_mode = "fixed_range"
print("normalize_mode=fixed_range: 固定物理范围归一化(免 stats)")
with HailoPredictor(args.hef, stats_path=args.stats,
predict_delta=predict_delta,
normalize_mode=normalize_mode) as predictor:
if len(args.images) != predictor.frame_len: if len(args.images) != predictor.frame_len:
raise SystemExit( raise SystemExit(
f"--images 需传入 {predictor.frame_len} 个路径(旧→新)," f"--images 需传入 {predictor.frame_len} 个路径(旧→新),"
+3 -1
View File
@@ -127,8 +127,10 @@ class ActionPredictor(nn.Module):
encoder_layer, num_layers=model_cfg.attn_layers, encoder_layer, num_layers=model_cfg.attn_layers,
enable_nested_tensor=False) enable_nested_tensor=False)
from bins import TOTAL_BINS
head_out = TOTAL_BINS if data_cfg.onehot else data_cfg.action_dim
self.head = PredictionHead( self.head = PredictionHead(
d, model_cfg.fused_hidden_dim, data_cfg.action_dim, model_cfg.dropout) d, model_cfg.fused_hidden_dim, head_out, model_cfg.dropout)
self._frame_len = data_cfg.frame_len self._frame_len = data_cfg.frame_len
self._history_len = data_cfg.history_len self._history_len = data_cfg.history_len
+1 -1
View File
@@ -30,7 +30,7 @@ def parse_mt_action(path) -> np.ndarray:
Raises: Raises:
ValueError: 文件中无 @Action 块、数据行不足、行列不一致或数值非法 ValueError: 文件中无 @Action 块、数据行不足、行列不一致或数值非法
""" """
text = Path(path).read_text(encoding="utf-8") text = Path(path).read_text(encoding="utf-8-sig")
# 先去除整行注释(# 开头),避免注释中的 @Action 干扰正则匹配 # 先去除整行注释(# 开头),避免注释中的 @Action 干扰正则匹配
text = re.sub(r"(?m)^[ \t]*#.*$", "", text) text = re.sub(r"(?m)^[ \t]*#.*$", "", text)
+63 -16
View File
@@ -18,21 +18,14 @@ import numpy as np
from PIL import Image from PIL import Image
from imu_norm import normalize_imu from imu_norm import normalize_imu
from action_norm import (normalize_action, denormalize_action,
is_range_mode)
IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32) IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
IMAGENET_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32) IMAGENET_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32)
def parse_action_history(s: str, action_dim: int) -> np.ndarray: from utils import parse_action_history
steps = s.split(":")
history = []
for step in steps:
values = [float(v) for v in step.split(",")]
if len(values) != action_dim:
raise ValueError(
f"Expected {action_dim} values per step, got {len(values)}")
history.append(values)
return np.array(history, dtype=np.float32)
class OnnxPredictor: class OnnxPredictor:
@@ -44,7 +37,19 @@ class OnnxPredictor:
""" """
def __init__(self, onnx_path: str, stats_path: str = None, def __init__(self, onnx_path: str, stats_path: str = None,
providers=None): providers=None, normalize_mode: str = None,
predict_delta: bool = None):
"""ONNX 推理器。
normalize_mode: 与训练时 DataConfig.normalize_mode 一致。
"zscore" 需传 stats_path;"fixed_range" 免 stats 文件,
按固定物理范围 min-max 归一化(action_norm.py);
"none" 表示训练未归一化,输入输出均不处理。
缺省 None 时从 ONNX 元数据自动识别(export_onnx 写入),
旧模型无元数据回退 "zscore"。
predict_delta: 残差模式(输出 Δ̂ 加回 history 末步得绝对动作)。
缺省 None 时同样从 ONNX 元数据识别,无元数据回退 False。
"""
import onnxruntime as ort import onnxruntime as ort
if providers is None: if providers is None:
@@ -68,9 +73,25 @@ class OnnxPredictor:
self._imu_name = (next(n for n, s in inputs.items() if len(s) == 2) self._imu_name = (next(n for n, s in inputs.items() if len(s) == 2)
if imu_shape is not None else None) if imu_shape is not None else None)
meta = self._read_metadata()
if normalize_mode is None:
normalize_mode = meta.get("normalize_mode") or "zscore"
if normalize_mode not in ("zscore", "fixed_range", "none",
"log_range"):
raise ValueError(f"normalize_mode={normalize_mode!r} 非法,"
"应为 zscore/fixed_range/none/log_range")
self._range_mode = (normalize_mode
if is_range_mode(normalize_mode) else None)
self._no_norm = normalize_mode == "none"
self.normalize_mode = normalize_mode # 解析后的最终模式(供调用方判断)
# R1-C 残差预测:元数据 predict_delta=1 时模型输出 Δ̂,
# 需在归一化空间加回 a_{t-1}(与 predict.py/hailo_infer 同语义)
self._predict_delta = (predict_delta if predict_delta is not None
else meta.get("predict_delta") == "1")
self._action_mean = None self._action_mean = None
self._action_std = None self._action_std = None
if stats_path is not None: if stats_path is not None and not (self._range_mode or self._no_norm):
if not os.path.exists(stats_path): if not os.path.exists(stats_path):
raise FileNotFoundError(f"统计文件不存在: {stats_path}") raise FileNotFoundError(f"统计文件不存在: {stats_path}")
stats = np.load(stats_path) stats = np.load(stats_path)
@@ -81,6 +102,13 @@ class OnnxPredictor:
self._action_mean = stats[0].astype(np.float32) self._action_mean = stats[0].astype(np.float32)
self._action_std = stats[1].astype(np.float32) self._action_std = stats[1].astype(np.float32)
def _read_metadata(self) -> dict:
"""读 ONNX custom metadata(export_onnx 写入);旧模型无则空 dict"""
try:
return dict(self._session.get_modelmeta().custom_metadata_map)
except Exception:
return {}
@staticmethod @staticmethod
def _locate_inputs(inputs): def _locate_inputs(inputs):
"""按维度数识别图像(5维)/动作历史(3维)/姿态(2维,可选)输入,返回各自形状""" """按维度数识别图像(5维)/动作历史(3维)/姿态(2维,可选)输入,返回各自形状"""
@@ -133,7 +161,10 @@ class OnnxPredictor:
raise ValueError( raise ValueError(
f"action_history 形状应为 {expected},实际为 {action_history.shape}") f"action_history 形状应为 {expected},实际为 {action_history.shape}")
if self._action_mean is not None: if self._range_mode:
action_history = normalize_action(action_history, self.action_dim,
mode=self._range_mode)
elif self._action_mean is not None:
action_history = (action_history - self._action_mean) / self._action_std action_history = (action_history - self._action_mean) / self._action_std
frames = np.stack([self._preprocess(img) for img in images]) frames = np.stack([self._preprocess(img) for img in images])
@@ -145,7 +176,13 @@ class OnnxPredictor:
feeds[self._imu_name] = normalize_imu( feeds[self._imu_name] = normalize_imu(
imu, self.imu_dim)[np.newaxis] # (1,imu_dim) imu, self.imu_dim)[np.newaxis] # (1,imu_dim)
result = self._session.run(None, feeds)[0].reshape(-1).astype(np.float32) result = self._session.run(None, feeds)[0].reshape(-1).astype(np.float32)
if self._action_mean is not None: if self._predict_delta:
# 残差:Δ̂ + a_{t-1}(归一化空间,与 predict.py 对齐)
result = result + action_history[-1]
if self._range_mode:
result = denormalize_action(result, self.action_dim,
mode=self._range_mode)
elif self._action_mean is not None:
result = result * self._action_std + self._action_mean result = result * self._action_std + self._action_mean
return result return result
@@ -167,7 +204,15 @@ def main():
parser.add_argument("--action-history", required=True, parser.add_argument("--action-history", required=True,
help="冒号分隔各步,每步 action_dim 个值") help="冒号分隔各步,每步 action_dim 个值")
parser.add_argument("--stats", default=None, parser.add_argument("--stats", default=None,
help="action_stats.npy 路径(训练时启用归一化才需要)") help="action_stats.npy 路径(zscore 归一化训练才需要)")
parser.add_argument("--normalize-mode",
choices=["zscore", "fixed_range", "none", "log_range"],
default=None,
help="与训练时 normalize_mode 一致;缺省从 ONNX 元数据"
"自动识别(旧模型回退 zscore)")
parser.add_argument("--predict-delta", action="store_true", default=None,
help="残差模式(输出加回 history 末步);缺省从 ONNX "
"元数据自动识别(旧模型回退关闭)")
parser.add_argument("--imu", type=str, default=None, parser.add_argument("--imu", type=str, default=None,
help="可选姿态向量(逗号分隔,原始量纲:roll,pitch 度 / " help="可选姿态向量(逗号分隔,原始量纲:roll,pitch 度 / "
"ax..gz int16 原始值);模型含 imu 输入且缺省时按零向量") "ax..gz int16 原始值);模型含 imu 输入且缺省时按零向量")
@@ -175,7 +220,9 @@ def main():
default="json") default="json")
args = parser.parse_args() args = parser.parse_args()
with OnnxPredictor(args.model, stats_path=args.stats) as predictor: with OnnxPredictor(args.model, stats_path=args.stats,
normalize_mode=args.normalize_mode,
predict_delta=args.predict_delta) as predictor:
if len(args.images) != predictor.frame_len: if len(args.images) != predictor.frame_len:
raise SystemExit( raise SystemExit(
f"--images 需传入 {predictor.frame_len} 个路径(旧→新)," f"--images 需传入 {predictor.frame_len} 个路径(旧→新),"
+45 -17
View File
@@ -8,7 +8,11 @@ from torchvision import transforms
from config import parse_checkpoint_config from config import parse_checkpoint_config
from imu_norm import normalize_imu from imu_norm import normalize_imu
from action_norm import (normalize_action, denormalize_action,
is_range_mode)
from bins import decode as bin_decode, fixed_zero_columns
from model import ActionPredictor from model import ActionPredictor
from utils import parse_action_history
class ActionPredictorInference: class ActionPredictorInference:
@@ -34,6 +38,7 @@ class ActionPredictorInference:
# pybot 闭环鸭子类型约定:此属性为 None 时按旧两参调用, # pybot 闭环鸭子类型约定:此属性为 None 时按旧两参调用,
# 故 imu_dim=0(旧 checkpoint)必须映射为 None。 # 故 imu_dim=0(旧 checkpoint)必须映射为 None。
self.imu_dim = self._data_cfg.imu_dim or None self.imu_dim = self._data_cfg.imu_dim or None
self._onehot = self._data_cfg.onehot
# R1-C 残差预测:checkpoint 记录 predict_delta=True 时,模型输出 Δ̂, # R1-C 残差预测:checkpoint 记录 predict_delta=True 时,模型输出 Δ̂,
# 需在归一化空间加回 a_{t-1}(history 末步)得到绝对动作。 # 需在归一化空间加回 a_{t-1}(history 末步)得到绝对动作。
# 旧 checkpoint 无该字段,getattr 默认 False,行为与旧版一致。 # 旧 checkpoint 无该字段,getattr 默认 False,行为与旧版一致。
@@ -48,7 +53,14 @@ class ActionPredictorInference:
self._action_mean = None self._action_mean = None
self._action_std = None self._action_std = None
if self._data_cfg.normalize_actions: # fixed_range / log_range:固定物理范围 → [0,1](免 stats 文件);
# 旧 checkpoint 无 normalize_mode 键,parse_checkpoint_config
# 默认 "zscore",行为不变
self._range_mode = (self._data_cfg.normalize_mode
if (self._data_cfg.normalize_actions
and is_range_mode(self._data_cfg.normalize_mode))
else None)
if self._data_cfg.normalize_actions and not self._range_mode:
# 优先使用 checkpoint config 记录的 stats 路径(相对路径按 checkpoint 目录解析), # 优先使用 checkpoint config 记录的 stats 路径(相对路径按 checkpoint 目录解析),
# 旧 checkpoint 无该字段时回退到 checkpoint 同目录的 action_stats.npy # 旧 checkpoint 无该字段时回退到 checkpoint 同目录的 action_stats.npy
stats_path = self._data_cfg.action_stats_path stats_path = self._data_cfg.action_stats_path
@@ -94,7 +106,11 @@ class ActionPredictorInference:
frames.append(self._transform(img)) frames.append(self._transform(img))
img_tensor = torch.stack(frames).unsqueeze(0).to(self._device) # (1, F, 3, S, S) img_tensor = torch.stack(frames).unsqueeze(0).to(self._device) # (1, F, 3, S, S)
history = torch.tensor(action_history, dtype=torch.float32) history = torch.tensor(action_history, dtype=torch.float32)
if self._action_mean is not None: if self._range_mode:
history = torch.from_numpy(
normalize_action(action_history, self.action_dim,
mode=self._range_mode))
elif self._action_mean is not None:
history = (history - self._action_mean) / self._action_std history = (history - self._action_mean) / self._action_std
history = history.unsqueeze(0).to(self._device) history = history.unsqueeze(0).to(self._device)
imu_tensor = None imu_tensor = None
@@ -103,24 +119,36 @@ class ActionPredictorInference:
normalize_imu(imu, self._data_cfg.imu_dim) normalize_imu(imu, self._data_cfg.imu_dim)
).unsqueeze(0).to(self._device) ).unsqueeze(0).to(self._device)
pred = self._model(img_tensor, history, imu_tensor).squeeze(0) # 归一化空间 pred = self._model(img_tensor, history, imu_tensor).squeeze(0) # 归一化空间
if self._onehot:
# 多头 logits → 期望值解码 → 原始量纲(箱生活在原始空间);
# 权重 0 列固定为常量(delay=10ms 等),防闭环垃圾回写
result = bin_decode(pred.cpu().numpy(), self.action_dim)
col_w = self._data_cfg.column_weights
if col_w is None:
from action_norm import default_column_weights
col_w = default_column_weights(self.action_dim)
fixed = fixed_zero_columns(col_w, self.action_dim)
# 按权重掩码覆盖(固定 0 的列也须覆盖——掩码不能只看常量值)
w = np.asarray(col_w, dtype=np.float32)
result[w[:self.action_dim] <= 0.0] = (
fixed[w[:self.action_dim] <= 0.0])
if not np.all(np.isfinite(result)):
result = np.nan_to_num(result, nan=0.0, posinf=0.0, neginf=0.0)
return result
if self._predict_delta: if self._predict_delta:
# 残差:Δ̂ 加回 a_{t-1}(history 末步,同为归一化空间)→ 绝对动作 # 残差:Δ̂ 加回 a_{t-1}(history 末步,同为归一化空间)→ 绝对动作
pred = history[0, -1] + pred pred = history[0, -1] + pred
if self._action_mean is not None: if self._range_mode:
pred = pred * self._action_std + self._action_mean result = denormalize_action(pred.cpu().numpy(), self.action_dim,
return pred.cpu().numpy() mode=self._range_mode)
else:
if self._action_mean is not None:
def parse_action_history(s: str, action_dim: int) -> np.ndarray: pred = pred * self._action_std + self._action_mean
steps = s.split(":") result = pred.cpu().numpy()
history = [] if not np.all(np.isfinite(result)):
for step in steps: print("WARNING: predict output contains NaN/Inf, replacing with zeros")
values = [float(v) for v in step.split(",")] result = np.nan_to_num(result, nan=0.0, posinf=0.0, neginf=0.0)
if len(values) != action_dim: return result
raise ValueError(
f"Expected {action_dim} values per step, got {len(values)}")
history.append(values)
return np.array(history, dtype=np.float32)
def main(): def main():
+66 -32
View File
@@ -33,6 +33,9 @@ from config import (Config, build_argparser, apply_overrides,
validate_config_values, warn_init_from_mismatch) validate_config_values, warn_init_from_mismatch)
from model import ActionPredictor from model import ActionPredictor
from mt_parser import parse_mt_directory from mt_parser import parse_mt_directory
from action_norm import (normalize_action, default_column_weights,
is_range_mode)
from train import _weighted_mse
_IMAGENET_MEAN = [0.485, 0.456, 0.406] _IMAGENET_MEAN = [0.485, 0.456, 0.406]
_IMAGENET_STD = [0.229, 0.224, 0.225] _IMAGENET_STD = [0.229, 0.224, 0.225]
@@ -46,11 +49,18 @@ class MTActionDataset(Dataset):
def __init__(self, mt_dir: str, history_len: int, action_dim: int, def __init__(self, mt_dir: str, history_len: int, action_dim: int,
file_indices: list = None, files: list = None, file_indices: list = None, files: list = None,
action_mean=None, action_std=None): action_mean=None, action_std=None,
normalize_mode: str = "zscore"):
self.history_len = history_len self.history_len = history_len
self.action_dim = action_dim self.action_dim = action_dim
self._action_mean = action_mean self._action_mean = action_mean
self._action_std = action_std self._action_std = action_std
# 固定范围类归一化(fixed_range / log_range,免统计量;
# action_mean 传入时优先 z-score)
self._range_mode = (normalize_mode
if (is_range_mode(normalize_mode)
and action_mean is None)
else None)
self.samples = [] self.samples = []
# 复用调用方已解析的结果(避免 train/val 各自重复解析同一目录) # 复用调用方已解析的结果(避免 train/val 各自重复解析同一目录)
@@ -83,6 +93,11 @@ class MTActionDataset(Dataset):
def __getitem__(self, idx): def __getitem__(self, idx):
history, target = self.samples[idx] history, target = self.samples[idx]
if self._range_mode:
history = normalize_action(history, self.action_dim,
mode=self._range_mode)
target = normalize_action(target, self.action_dim,
mode=self._range_mode)
history = torch.from_numpy(history.copy()) history = torch.from_numpy(history.copy())
target = torch.from_numpy(target.copy()) target = torch.from_numpy(target.copy())
if self._action_mean is not None: if self._action_mean is not None:
@@ -110,7 +125,7 @@ def _placeholder_images(batch, frame_len, image_height, image_width,
def pretrain_one_epoch(model, loader, optimizer, device, image_width, def pretrain_one_epoch(model, loader, optimizer, device, image_width,
image_height, frame_len, image_height, frame_len,
max_grad_norm=None, noise_std=0.0, max_grad_norm=None, noise_std=0.0,
random_images: bool = False): random_images: bool = False, weights=None):
model.train() model.train()
total_loss = 0.0 total_loss = 0.0
count = 0 count = 0
@@ -121,13 +136,13 @@ def pretrain_one_epoch(model, loader, optimizer, device, image_width,
if noise_std > 0: if noise_std > 0:
history = history + torch.randn_like(history) * noise_std history = history + torch.randn_like(history) * noise_std
target = target + torch.randn_like(target) * noise_std target = target + torch.randn_like(target) * noise_std
if images is None or images.size(0) != history.size(0): if images is None or images.size(0) != history.size(0) or random_images:
images = _placeholder_images( images = _placeholder_images(
history.size(0), frame_len, image_height, image_width, history.size(0), frame_len, image_height, image_width,
device, random_images) device, random_images)
optimizer.zero_grad() optimizer.zero_grad()
pred = model(images, history) pred = model(images, history)
loss = torch.nn.functional.mse_loss(pred, target) loss = _weighted_mse(pred, target, weights)
loss.backward() loss.backward()
if max_grad_norm: if max_grad_norm:
torch.nn.utils.clip_grad_norm_(model.parameters(), max_grad_norm) torch.nn.utils.clip_grad_norm_(model.parameters(), max_grad_norm)
@@ -139,7 +154,7 @@ def pretrain_one_epoch(model, loader, optimizer, device, image_width,
@torch.no_grad() @torch.no_grad()
def validate(model, loader, device, image_width, image_height, frame_len, def validate(model, loader, device, image_width, image_height, frame_len,
random_images: bool = False): random_images: bool = False, weights=None):
model.eval() model.eval()
total_loss = 0.0 total_loss = 0.0
count = 0 count = 0
@@ -147,12 +162,12 @@ def validate(model, loader, device, image_width, image_height, frame_len,
for batch in tqdm(loader, desc=" val ", leave=False): for batch in tqdm(loader, desc=" val ", leave=False):
history = batch["action_history"].to(device) history = batch["action_history"].to(device)
target = batch["target"].to(device) target = batch["target"].to(device)
if images is None or images.size(0) != history.size(0): if images is None or images.size(0) != history.size(0) or random_images:
images = _placeholder_images( images = _placeholder_images(
history.size(0), frame_len, image_height, image_width, history.size(0), frame_len, image_height, image_width,
device, random_images) device, random_images)
pred = model(images, history) pred = model(images, history)
loss = torch.nn.functional.mse_loss(pred, target) loss = _weighted_mse(pred, target, weights)
total_loss += loss.item() * history.size(0) total_loss += loss.item() * history.size(0)
count += history.size(0) count += history.size(0)
if count == 0: if count == 0:
@@ -173,7 +188,6 @@ def main():
cfg = apply_overrides(Config(), args) cfg = apply_overrides(Config(), args)
validate_config_values(cfg.data, cfg.model, cfg.train) validate_config_values(cfg.data, cfg.model, cfg.train)
cfg.data.data_dir = args.mt_dir
os.makedirs(cfg.train.checkpoint_dir, exist_ok=True) os.makedirs(cfg.train.checkpoint_dir, exist_ok=True)
@@ -205,12 +219,18 @@ def main():
train_ds = MTActionDataset( train_ds = MTActionDataset(
args.mt_dir, cfg.data.history_len, cfg.data.action_dim, args.mt_dir, cfg.data.history_len, cfg.data.action_dim,
file_indices=train_file_indices, files=all_files, file_indices=train_file_indices, files=all_files,
normalize_mode=cfg.data.normalize_mode,
) )
# 动作归一化(与 train.py 语义一致):无 stats 文件时按训练集 # 动作归一化(与 train.py 语义一致):zscore 模式无 stats 文件时按训练集
# targets 计算并保存到 checkpoint_dir,验证集复用同一 stats # targets 计算并保存到 checkpoint_dir,验证集复用同一 stats;
# fixed_range 模式免统计量,跳过此段
action_mean = action_std = None action_mean = action_std = None
if cfg.data.normalize_actions: if cfg.data.normalize_actions and is_range_mode(cfg.data.normalize_mode):
# 构造时 action_mean=None 已置 _range_mode,这里只打印口径
print(f"动作归一化: {cfg.data.normalize_mode}"
f"(固定物理范围→[0,1],免 stats)")
elif cfg.data.normalize_actions:
if cfg.data.action_stats_path: if cfg.data.action_stats_path:
if not os.path.exists(cfg.data.action_stats_path): if not os.path.exists(cfg.data.action_stats_path):
raise FileNotFoundError( raise FileNotFoundError(
@@ -248,6 +268,7 @@ def main():
args.mt_dir, cfg.data.history_len, cfg.data.action_dim, args.mt_dir, cfg.data.history_len, cfg.data.action_dim,
file_indices=val_file_indices, files=all_files, file_indices=val_file_indices, files=all_files,
action_mean=action_mean, action_std=action_std, action_mean=action_mean, action_std=action_std,
normalize_mode=cfg.data.normalize_mode,
) )
print(f"Files: {len(train_file_indices)} train, {len(val_file_indices)} val") print(f"Files: {len(train_file_indices)} train, {len(val_file_indices)} val")
@@ -272,6 +293,13 @@ def main():
model = ActionPredictor(cfg.model, cfg.data, pretrained=False).to(device) model = ActionPredictor(cfg.model, cfg.data, pretrained=False).to(device)
n_params = sum(p.numel() for p in model.parameters()) n_params = sum(p.numel() for p in model.parameters())
print(f"Model params: {n_params:,}") print(f"Model params: {n_params:,}")
# 逐列损失权重(与 train.py 同口径;仅训练损失用)
col_w = cfg.data.column_weights
if col_w is None:
col_w = default_column_weights(cfg.data.action_dim)
weights_t = torch.tensor(np.asarray(col_w, dtype=np.float32), device=device)
print(f"Column weights: {[round(float(v), 4) for v in weights_t.tolist()]}")
print(f"Config: action_dim={cfg.data.action_dim}, " print(f"Config: action_dim={cfg.data.action_dim}, "
f"history_len={cfg.data.history_len}, " f"history_len={cfg.data.history_len}, "
f"frame_len={cfg.data.frame_len}, " f"frame_len={cfg.data.frame_len}, "
@@ -328,34 +356,40 @@ def main():
model, train_loader, optimizer, device, model, train_loader, optimizer, device,
cfg.data.image_width, cfg.data.image_height, cfg.data.frame_len, cfg.data.image_width, cfg.data.image_height, cfg.data.frame_len,
cfg.train.max_grad_norm, args.noise_std, args.random_images, cfg.train.max_grad_norm, args.noise_std, args.random_images,
weights=weights_t,
) )
val_loss = validate(model, val_loader, device, val_loss = validate(model, val_loader, device,
cfg.data.image_width, cfg.data.image_height, cfg.data.image_width, cfg.data.image_height,
cfg.data.frame_len, args.random_images) cfg.data.frame_len, args.random_images,
weights=weights_t)
scheduler.step() scheduler.step()
print(f" train_loss={train_loss:.6f} val_loss={val_loss:.6f}") print(f" train_loss={train_loss:.6f} val_loss={val_loss:.6f}")
if val_loss < best_val_loss: if val_loss < best_val_loss:
best_val_loss = val_loss if not np.isfinite(val_loss):
patience_counter = 0 print(f"WARNING: val_loss 非有限值({val_loss}),跳过 best 更新")
torch.save( patience_counter += 1
{ else:
"epoch": epoch, best_val_loss = val_loss
"model_state_dict": model.state_dict(), patience_counter = 0
"optimizer_state_dict": optimizer.state_dict(), torch.save(
"val_loss": val_loss, {
"config": cfg.to_dict(), "epoch": epoch,
}, "model_state_dict": model.state_dict(),
best_path, "optimizer_state_dict": optimizer.state_dict(),
) "val_loss": val_loss,
torch.save( "config": cfg.to_dict(),
{ },
"model_state_dict": model.state_dict(), best_path,
"config": cfg.to_dict(), )
}, torch.save(
inference_path, {
) "model_state_dict": model.state_dict(),
print(f" -> saved best (val_loss={val_loss:.6f})") "config": cfg.to_dict(),
},
inference_path,
)
print(f" -> saved best (val_loss={val_loss:.6f})")
else: else:
patience_counter += 1 patience_counter += 1
if patience_counter >= cfg.train.patience: if patience_counter >= cfg.train.patience:
+4
View File
@@ -4,5 +4,9 @@ torchvision>=0.15,<1.0
numpy>=1.24,<3.0 numpy>=1.24,<3.0
Pillow>=9.0,<14.0 # torchvision 依赖,上限放宽避免与 torch 源安装冲突 Pillow>=9.0,<14.0 # torchvision 依赖,上限放宽避免与 torch 源安装冲突
# 推理(Hailo 部署):帧归一化+量化合并查表(hailo_infer.py cv2.LUT 快路径,
# 无 cv2 自动回退 float 路径——本依赖缺失仅损失 3.3× 端到端加速)
opencv-python-headless>=4.9,<6.0
# Training utilities # Training utilities
tqdm>=4.60,<5.0 tqdm>=4.60,<5.0
+834 -9
View File
@@ -666,6 +666,8 @@ class TestParseCheckpointConfig(unittest.TestCase):
"""--init-from 兼容性警告:normalize 或 action_dim 不一致时提示""" """--init-from 兼容性警告:normalize 或 action_dim 不一致时提示"""
from config import warn_init_from_mismatch from config import warn_init_from_mismatch
cfg = Config() cfg = Config()
# 显式设定当前运行的 normalize 值,不依赖默认值(默认已改为 True)
cfg.data.normalize_actions = False
init_cfg = { init_cfg = {
"data": {"normalize_actions": True, "action_dim": 12}, "data": {"normalize_actions": True, "action_dim": 12},
} }
@@ -1085,6 +1087,139 @@ class TestOnnxPredictor(unittest.TestCase):
OnnxPredictor(path, stats_path=stats_path) OnnxPredictor(path, stats_path=stats_path)
@unittest.skipUnless(
importlib.util.find_spec("onnxscript") is not None
and importlib.util.find_spec("onnxruntime") is not None,
"ONNX 元数据测试需要 onnxscript + onnxruntime")
class TestOnnxMetadata(unittest.TestCase):
"""v3-P1-2:export_onnx 写入训练配置元数据,onnx_infer 自动识别
覆盖 normalize_mode(zscore/fixed_range/none)与 predict_delta 的
元数据写入、自动识别、显式参数覆盖与残差加回数值语义。
"""
@staticmethod
def _write_meta(path, meta):
import onnx
m = onnx.load(path)
for k, v in meta.items():
p = m.metadata_props.add()
p.key, p.value = k, v
onnx.save(m, path)
@staticmethod
def _read_meta(path):
import onnx
return {p.key: p.value for p in onnx.load(path).metadata_props}
def _frames(self, n):
return [np.random.randint(0, 255, (48, 64, 3), dtype=np.uint8)
for _ in range(n)]
def _export_via_script(self, tmpdir, normalize_actions=False,
normalize_mode="zscore", predict_delta=False):
"""经 export_onnx.main 导出小模型,返回 ONNX 路径"""
import export_onnx
cfg = Config()
cfg.data.image_width = 32
cfg.data.image_height = 32
cfg.data.action_dim = 4
cfg.data.history_len = 3
cfg.data.frame_len = 3
cfg.data.normalize_actions = normalize_actions
cfg.data.normalize_mode = normalize_mode
cfg.train.predict_delta = predict_delta
model = ActionPredictor(cfg.model, cfg.data)
model.eval()
ckpt = os.path.join(tmpdir, "best_inference.pt")
torch.save({"model_state_dict": model.state_dict(),
"config": cfg.to_dict()}, ckpt)
out = os.path.join(tmpdir, "model.onnx")
with mock.patch.object(
sys, "argv",
["export_onnx.py", "--model", ckpt, "--output", out]):
with contextlib.redirect_stdout(io.StringIO()):
export_onnx.main()
return out
def test_export_writes_metadata_unnormalized(self):
with tempfile.TemporaryDirectory() as tmpdir:
meta = self._read_meta(self._export_via_script(tmpdir))
self.assertEqual(meta["normalize_mode"], "none") # 未归一化训练
self.assertEqual(meta["predict_delta"], "0")
self.assertEqual(meta["action_dim"], "4")
self.assertEqual(meta["imu_dim"], "0")
def test_export_writes_metadata_fixed_range_delta(self):
with tempfile.TemporaryDirectory() as tmpdir:
meta = self._read_meta(self._export_via_script(
tmpdir, normalize_actions=True,
normalize_mode="fixed_range", predict_delta=True))
self.assertEqual(meta["normalize_mode"], "fixed_range")
self.assertEqual(meta["predict_delta"], "1")
def test_export_metadata_roundtrip_predictor(self):
"""导出的 ONNX 直接被 OnnxPredictor 自动识别,无需显式参数"""
from onnx_infer import OnnxPredictor
with tempfile.TemporaryDirectory() as tmpdir:
out = self._export_via_script(
tmpdir, normalize_actions=True,
normalize_mode="fixed_range", predict_delta=True)
with OnnxPredictor(out) as p:
self.assertEqual(p.normalize_mode, "fixed_range")
self.assertTrue(p._predict_delta)
def test_predictor_metadata_fallback_old_model(self):
"""旧版无元数据 ONNX:回退 zscore + 非残差"""
from onnx_infer import OnnxPredictor
with tempfile.TemporaryDirectory() as tmpdir:
path, _ = TestOnnxPredictor._export_small_onnx(tmpdir)
with OnnxPredictor(path) as p:
self.assertEqual(p.normalize_mode, "zscore")
self.assertFalse(p._predict_delta)
def test_explicit_param_overrides_metadata(self):
from onnx_infer import OnnxPredictor
with tempfile.TemporaryDirectory() as tmpdir:
path, _ = TestOnnxPredictor._export_small_onnx(tmpdir)
self._write_meta(path, {"normalize_mode": "fixed_range",
"predict_delta": "1"})
with OnnxPredictor(path, normalize_mode="zscore",
predict_delta=False) as p:
self.assertEqual(p.normalize_mode, "zscore")
self.assertFalse(p._predict_delta)
def test_predict_delta_adds_last_history(self):
"""残差输出 = 普通输出 + history 末步(none 模式下口径直接可比)"""
from onnx_infer import OnnxPredictor
with tempfile.TemporaryDirectory() as tmpdir:
path, _ = TestOnnxPredictor._export_small_onnx(tmpdir)
frames = self._frames(3)
history = np.random.randn(3, 4).astype(np.float32)
with OnnxPredictor(path, normalize_mode="none") as plain:
base = plain.predict(frames, history)
with OnnxPredictor(path, normalize_mode="none",
predict_delta=True) as delta:
out = delta.predict(frames, history)
np.testing.assert_allclose(out, base + history[-1], atol=1e-5)
def test_none_mode_ignores_stats(self):
"""none 模式(训练未归一化):即使传 stats 也不做归一化"""
from onnx_infer import OnnxPredictor
with tempfile.TemporaryDirectory() as tmpdir:
path, _ = TestOnnxPredictor._export_small_onnx(tmpdir)
stats_path = os.path.join(tmpdir, "action_stats.npy")
np.save(stats_path, np.stack([np.full(4, 0.5), np.full(4, 2.0)]))
frames = self._frames(3)
history = np.random.randn(3, 4).astype(np.float32)
with OnnxPredictor(path, stats_path=stats_path,
normalize_mode="none") as p:
out = p.predict(frames, history)
with OnnxPredictor(path, normalize_mode="none") as q:
ref = q.predict(frames, history)
np.testing.assert_allclose(out, ref, atol=1e-6)
# ---------------- HailoPredictor 测试用 fake hailo_platform ---------------- # ---------------- HailoPredictor 测试用 fake hailo_platform ----------------
class _FakeFormatType: class _FakeFormatType:
@@ -1144,6 +1279,13 @@ class _FakeConfigured:
for b in bindings_list: for b in bindings_list:
self._transform(b) self._transform(b)
def run_async(self, bindings_list, callback=None):
# 模拟异步:同步完成 transform 后触发回调(completion_info 无异常)
for b in bindings_list:
self._transform(b)
if callback is not None:
callback(completion_info=types.SimpleNamespace(exception=None))
class _FakeInferModel: class _FakeInferModel:
def __init__(self, transform): def __init__(self, transform):
@@ -1447,6 +1589,40 @@ class TestHailoPredictor(unittest.TestCase):
result = p.predict(self._frames(2), history) result = p.predict(self._frames(2), history)
np.testing.assert_allclose(result, [3, 3, 3], atol=1e-6) np.testing.assert_allclose(result, [3, 3, 3], atol=1e-6)
def test_predict_async_matches_predict(self):
from hailo_infer import HailoPredictor
infos_in = [
_FakeVStreamInfo("net/images", (1, 2, 3, 4, 4), _FakeFormatType.FLOAT32),
_FakeVStreamInfo("net/history", (1, 2, 3), _FakeFormatType.FLOAT32),
]
infos_out = [_FakeVStreamInfo("net/action", (1, 3), _FakeFormatType.FLOAT32)]
_install_fake_hailo(self, infos_in, infos_out,
_first_row_transform("net/history", "net/action"))
history = np.array([[1, 2, 3], [4, 5, 6]], dtype=np.float32)
with HailoPredictor("fake.hef") as p:
sync = p.predict(self._frames(2), history)
job = p.predict_async(self._frames(2), history)
async_result = job.result(timeout=5.0)
np.testing.assert_allclose(async_result, sync, atol=1e-6)
def test_predict_async_buffer_pool_roundtrip(self):
"""连续提交超过缓冲池数量的任务,输出互不串扰"""
from hailo_infer import HailoPredictor
infos_in = [
_FakeVStreamInfo("net/images", (1, 2, 3, 4, 4), _FakeFormatType.FLOAT32),
_FakeVStreamInfo("net/history", (1, 2, 3), _FakeFormatType.FLOAT32),
]
infos_out = [_FakeVStreamInfo("net/action", (1, 3), _FakeFormatType.FLOAT32)]
_install_fake_hailo(self, infos_in, infos_out,
_first_row_transform("net/history", "net/action"))
with HailoPredictor("fake.hef") as p:
jobs = []
for _ in range(6): # 超过 _ASYNC_BUFFERS=4
jobs.append(p.predict_async(self._frames(2), [[1, 2, 3]] * 2))
for job in jobs:
np.testing.assert_allclose(job.result(timeout=5.0), [1, 2, 3],
atol=1e-6)
class TestDfcCompile(unittest.TestCase): class TestDfcCompile(unittest.TestCase):
"""dfc_compile.py 的 HAR 输入层连通性检查(编译前拦截输入断连)""" """dfc_compile.py 的 HAR 输入层连通性检查(编译前拦截输入断连)"""
@@ -1788,17 +1964,284 @@ class TestR4Evaluate(unittest.TestCase):
self.assertGreater(rep["error_growth"], 1.0) self.assertGreater(rep["error_growth"], 1.0)
self.assertEqual(len(rep["error_curve"]), 40) self.assertEqual(len(rep["error_curve"]), 40)
def test_action_bounds_margin(self): def test_action_bounds_physical(self):
"""越界判据为固定物理界(与 action_norm 常量表共用),不再依赖数据统计"""
import evaluate import evaluate
lo, hi = evaluate._action_bounds(12)
np.testing.assert_allclose(lo, np.zeros(12), atol=1e-6)
np.testing.assert_allclose(hi[:10], [180.0] * 10, atol=1e-6)
np.testing.assert_allclose(hi[10:], [5000.0, 2000.0], atol=1e-6)
# action_dim > 18 的超出列按 [0,1] 补足
lo20, hi20 = evaluate._action_bounds(20)
self.assertEqual(len(hi20), 20)
np.testing.assert_allclose(hi20[18:], [1.0, 1.0], atol=1e-6)
def test_error_growth_per_episode_median(self):
"""error_growth 按 episode 归一化取中位数,不被长短混叠主导"""
import evaluate
# ep1: 40 步线性增长 → g1 = mean(31..40)/mean(1..10) = 35.5/5.5
ep1 = [{"t": k, "mse": float(k + 1), "pred": np.zeros(2),
"true": np.zeros(2), "oob": False} for k in range(40)]
# ep2: 8 步末段尖峰 → g2 = 100/1
ep2 = [{"t": k, "mse": (1.0 if k < 6 else 100.0), "pred": np.zeros(2),
"true": np.zeros(2), "oob": False} for k in range(8)]
rep = evaluate.summarize([ep1, ep2], None, None)
g1 = 35.5 / 5.5
g2 = 100.0
self.assertAlmostEqual(rep["error_growth"], float(np.median([g1, g2])),
places=5)
class TestFixedRangeNorm(unittest.TestCase):
"""固定物理范围归一化 + 逐列加权:action_norm 常量表、dataset/predict 链路、
config 校验、加权损失"""
# ---------------- action_norm.py ----------------
def test_normalize_known_values(self):
from action_norm import normalize_action
a = np.zeros(18, dtype=np.float32)
a[0] = 90.0 # 舵机中位
a[10] = 2500.0 # spk.F 中位
a[11] = 1000.0 # spk.delay 中位
out = normalize_action(a)
self.assertAlmostEqual(float(out[0]), 0.5, places=6)
self.assertAlmostEqual(float(out[10]), 0.5, places=6)
self.assertAlmostEqual(float(out[11]), 0.5, places=6)
# 保留位恒 0 → 归一化后仍 0
np.testing.assert_allclose(out[12:], 0.0, atol=1e-7)
def test_normalize_clamps_out_of_range(self):
from action_norm import normalize_action
a = np.array([-10.0, 270.0] + [0.0] * 16, dtype=np.float32)
out = normalize_action(a)
self.assertEqual(float(out[0]), 0.0)
self.assertEqual(float(out[1]), 1.0)
def test_denormalize_roundtrip(self):
from action_norm import normalize_action, denormalize_action
rng = np.random.RandomState(0)
a = np.zeros((7, 18), dtype=np.float32)
a[:, :10] = rng.uniform(0, 180, (7, 10))
a[:, 10] = rng.uniform(0, 5000, 7)
a[:, 11] = rng.uniform(0, 2000, 7)
np.testing.assert_allclose(
denormalize_action(normalize_action(a)), a, rtol=1e-5, atol=1e-4)
def test_action_dim_prefix_and_padding(self):
from action_norm import normalize_action, action_bounds
# action_dim < 18 取前缀
lo4, hi4 = action_bounds(4)
np.testing.assert_allclose(hi4, [180.0] * 4)
out = normalize_action(np.array([90.0, 45.0, 0.0, 180.0],
dtype=np.float32))
np.testing.assert_allclose(out, [0.5, 0.25, 0.0, 1.0], atol=1e-6)
# action_dim > 18 按 [0,1] 补足
lo20, hi20 = action_bounds(20)
self.assertEqual(len(lo20), 20)
np.testing.assert_allclose(hi20[18:], [1.0, 1.0])
def test_default_column_weights(self):
from action_norm import default_column_weights
w18 = default_column_weights(18)
np.testing.assert_allclose(w18[:10], 1.0)
np.testing.assert_allclose(w18[10:], 0.0) # spk 两列 + 保留位全 0
# 小 action_dim:全部落在舵机段 → 全 1.0
np.testing.assert_allclose(default_column_weights(4), 1.0)
# 12 列:舵机 1.0,spk 两列 0
w12 = default_column_weights(12)
np.testing.assert_allclose(w12[10:], 0.0)
# 20 列:超出 18 列语义布局的扩展列默认 1.0(新执行器须参与训练)
w20 = default_column_weights(20)
np.testing.assert_allclose(w20[12:18], 0.0)
np.testing.assert_allclose(w20[18:], 1.0)
# ---------------- dataset 链路 ----------------
def test_dataset_fixed_range_no_stats_needed(self):
"""fixed_range 模式不需要 action_stats.npy,目标值按物理范围归一化"""
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
self._create_episode(Path(tmpdir), "episode_0001", base = Path(tmpdir)
np.zeros((5, 2), dtype=np.float32)) ep_dir = base / "episode_0001"
self._create_episode(Path(tmpdir), "episode_0002", images_dir = ep_dir / "images"
np.full((5, 2), 10.0, dtype=np.float32)) images_dir.mkdir(parents=True)
lo, hi = evaluate._action_bounds(tmpdir, 2, margin_ratio=0.1) for i in range(8):
# min=0,max=10,span=10,margin=1 → lo=-1, hi=11 Image.fromarray(np.random.randint(
np.testing.assert_allclose(lo, [-1.0, -1.0], atol=1e-6) 0, 255, (16, 16, 3), dtype=np.uint8)).save(
np.testing.assert_allclose(hi, [11.0, 11.0], atol=1e-6) images_dir / f"{i:04d}.jpg")
actions = np.zeros((8, 4), dtype=np.float32)
actions[:, 0] = 90.0 # 舵机中位 → 0.5
actions[:, 1] = 180.0 # 满行程 → 1.0
np.save(ep_dir / "actions.npy", actions)
ds = ActionDataset(tmpdir, history_len=3, action_dim=4,
image_width=16, image_height=16, frame_len=3,
normalize_actions=True,
normalize_mode="fixed_range")
sample = ds[0]
np.testing.assert_allclose(sample["target"].numpy()[:2],
[0.5, 1.0], atol=1e-6)
np.testing.assert_allclose(
sample["action_history"].numpy()[:, 0], 0.5, atol=1e-6)
# fixed_range 不产生统计量
self.assertIsNone(ds.get_action_stats())
def test_dataset_fixed_range_clamps(self):
"""越界值 clamp 到 [0,1](防御口径)"""
with tempfile.TemporaryDirectory() as tmpdir:
base = Path(tmpdir)
ep_dir = base / "episode_0001"
images_dir = ep_dir / "images"
images_dir.mkdir(parents=True)
for i in range(8):
Image.fromarray(np.random.randint(
0, 255, (16, 16, 3), dtype=np.uint8)).save(
images_dir / f"{i:04d}.jpg")
actions = np.zeros((8, 4), dtype=np.float32)
actions[:, 0] = 999.0 # 远超 180 → clamp 1.0
np.save(ep_dir / "actions.npy", actions)
ds = ActionDataset(tmpdir, history_len=3, action_dim=4,
image_width=16, image_height=16, frame_len=3,
normalize_actions=True,
normalize_mode="fixed_range")
self.assertEqual(float(ds[0]["target"][0]), 1.0)
def test_dataset_zscore_unchanged(self):
"""zscore 默认模式行为不变(仍计算/加载统计量)"""
with tempfile.TemporaryDirectory() as tmpdir:
TestActionDataset._create_dummy_episode(
Path(tmpdir), n_frames=10, action_dim=4)
ds = ActionDataset(tmpdir, history_len=3, action_dim=4,
image_width=32, image_height=32, frame_len=3,
normalize_actions=True)
self.assertIsNotNone(ds._action_mean)
self.assertIsNone(ds._range_mode)
def test_mt_dataset_fixed_range(self):
"""MTActionDataset fixed_range:免统计量,按物理范围归一化"""
with tempfile.TemporaryDirectory() as tmpdir:
_write_mt(tmpdir, "a.mt", _make_seq_mt(6))
with contextlib.redirect_stdout(io.StringIO()):
ds = MTActionDataset(tmpdir, history_len=3, action_dim=3,
normalize_mode="fixed_range")
sample = ds[0]
# _make_seq_mt 第 i 行 [i, 10i, 100i],target 为第 4 行 [3,30,300]
# 除数 180 → [3/180, 30/180, 300/180(clamp 1.0)]
np.testing.assert_allclose(
sample["target"].numpy(),
[3 / 180, 30 / 180, 1.0], atol=1e-6)
# ---------------- config 校验与 CLI ----------------
def test_cli_normalize_mode_and_column_weights(self):
parser = build_argparser()
args = parser.parse_args(
["--normalize-mode", "fixed_range",
"--column-weights", "1,1,1,0.2"])
cfg = apply_overrides(Config(), args)
cfg.data.action_dim = 4
self.assertEqual(cfg.data.normalize_mode, "fixed_range")
self.assertEqual(cfg.data.column_weights, [1.0, 1.0, 1.0, 0.2])
validate_config_values(cfg.data, cfg.model) # 不应抛异常
def test_bad_normalize_mode_raises(self):
cfg = Config()
cfg.data.normalize_mode = "minmax" # 非法值
with self.assertRaisesRegex(ValueError, "normalize_mode"):
validate_config_values(cfg.data, cfg.model)
def test_column_weights_length_mismatch_raises(self):
cfg = Config()
cfg.data.column_weights = [1.0, 0.2] # action_dim=18 不匹配
with self.assertRaisesRegex(ValueError, "column_weights"):
validate_config_values(cfg.data, cfg.model)
def test_column_weights_all_zero_raises(self):
cfg = Config()
cfg.data.column_weights = [0.0] * 18
with self.assertRaisesRegex(ValueError, "column_weights"):
validate_config_values(cfg.data, cfg.model)
def test_checkpoint_roundtrip_new_keys(self):
"""新键随 checkpoint 序列化/解析往返一致;旧 checkpoint 缺省兼容"""
cfg = Config()
cfg.data.normalize_mode = "fixed_range"
cfg.data.column_weights = [1.0] * 10 + [0.2] * 2 + [0.0] * 6
parsed = parse_checkpoint_config(cfg.to_dict())
self.assertEqual(parsed.data.normalize_mode, "fixed_range")
self.assertEqual(parsed.data.column_weights,
[1.0] * 10 + [0.2] * 2 + [0.0] * 6)
# 旧 checkpoint 无新键 → 默认 zscore / None,行为不变
d = cfg.to_dict()
del d["data"]["normalize_mode"]
del d["data"]["column_weights"]
parsed = parse_checkpoint_config(d)
self.assertEqual(parsed.data.normalize_mode, "zscore")
self.assertIsNone(parsed.data.column_weights)
def test_warn_init_from_normalize_mode_mismatch(self):
from config import warn_init_from_mismatch
cfg = Config()
cfg.data.normalize_mode = "fixed_range"
init_cfg = {"data": {"normalize_actions": True,
"normalize_mode": "zscore"}}
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
warn_init_from_mismatch(init_cfg, cfg)
self.assertIn("normalize_mode", buf.getvalue())
# ---------------- 加权损失 ----------------
def test_weighted_mse(self):
from train import _weighted_mse
pred = torch.tensor([[1.0, 2.0, 3.0]])
target = torch.zeros(1, 3)
# 等权 → 与普通 MSE 一致
w1 = torch.ones(3)
self.assertAlmostEqual(
float(_weighted_mse(pred, target, w1)),
float(torch.nn.functional.mse_loss(pred, target)), places=6)
# 只留第 0 列权重:loss = (1·1²)/(1) = 1
w = torch.tensor([1.0, 0.0, 0.0])
self.assertAlmostEqual(float(_weighted_mse(pred, target, w)), 1.0,
places=6)
# None 退化为普通 MSE
self.assertAlmostEqual(
float(_weighted_mse(pred, target, None)),
float(torch.nn.functional.mse_loss(pred, target)), places=6)
# ---------------- predict 链路 ----------------
def test_predict_fixed_range_no_stats(self):
"""fixed_range checkpoint 无需 action_stats.npy,归一化口径与
action_norm 一致(与 raw 模型 + 手工归一化对照)"""
from action_norm import normalize_action, denormalize_action
with tempfile.TemporaryDirectory() as tmpdir:
raw_dir = os.path.join(tmpdir, "raw")
fixed_dir = os.path.join(tmpdir, "fixed")
os.makedirs(raw_dir)
os.makedirs(fixed_dir)
torch.manual_seed(0)
path, model, cfg = \
TestActionPredictorInference._make_checkpoint(raw_dir)
# 同一份权重另存为 fixed_range 归一化 checkpoint(无 stats 文件)
cfg.data.normalize_actions = True
cfg.data.normalize_mode = "fixed_range"
fixed_path = os.path.join(fixed_dir, "best_inference.pt")
torch.save({"model_state_dict": model.state_dict(),
"config": cfg.to_dict()}, fixed_path)
image = np.random.randint(0, 255, (32, 32, 3), dtype=np.uint8)
history = np.random.uniform(0, 180, (3, 4)).astype(np.float32)
# fixed_range 端:内部 normalize → 模型 → denormalize
pred_fixed = ActionPredictorInference(
fixed_path, device="cpu").predict([image] * 3, history)
# 对照:raw 模型直接吃手工归一化的历史,再手工反归一化
pred_raw = ActionPredictorInference(raw_dir + "/best_inference.pt",
device="cpu").predict(
[image] * 3, normalize_action(history, 4))
np.testing.assert_allclose(
pred_fixed, denormalize_action(pred_raw, 4),
rtol=1e-5, atol=1e-5)
class TestR2ImuNorm(unittest.TestCase): class TestR2ImuNorm(unittest.TestCase):
@@ -1851,6 +2294,388 @@ class TestR2ImuNorm(unittest.TestCase):
out, ([1.0, -1.0] + [1.0] * 6) * 2, atol=1e-6) out, ([1.0, -1.0] + [1.0] * 6) * 2, atol=1e-6)
class TestLogRangeNorm(unittest.TestCase):
"""log_range 归一化:spk 两列对数尺度,其余列与 fixed_range 一致"""
def test_spk_cols_log_normalized(self):
"""spk.F/spk.delay 按 log10(1+x)/log10(1+hi) 归一化,低端值铺开"""
from action_norm import normalize_action, action_bounds
a = np.zeros(18, dtype=np.float32)
a[10] = 10.0 # 常见频率:线性尺度 0.002 → 对数 0.27
a[11] = 10.0 # 常见时延:线性尺度 0.005 → 对数 0.31
out = normalize_action(a, mode="log_range")
_, hi = action_bounds(18)
self.assertAlmostEqual(float(out[10]),
float(np.log10(11.0) / np.log10(hi[10] + 1.0)),
places=6)
self.assertAlmostEqual(float(out[11]),
float(np.log10(11.0) / np.log10(hi[11] + 1.0)),
places=6)
self.assertGreater(float(out[10]), 0.25) # 不再挤在 0.002
self.assertGreater(float(out[11]), 0.25)
def test_servo_cols_stay_linear(self):
"""log_range 下舵机列与 fixed_range 完全一致"""
from action_norm import normalize_action
a = np.zeros(18, dtype=np.float32)
a[:10] = [0.0, 45.0, 90.0, 135.0, 180.0, 0.0, 45.0, 90.0, 135.0, 180.0]
lin = normalize_action(a, mode="fixed_range")
log = normalize_action(a, mode="log_range")
np.testing.assert_allclose(log[:10], lin[:10], atol=1e-7)
np.testing.assert_allclose(log[12:], lin[12:], atol=1e-7)
def test_log_roundtrip(self):
"""log_range 归一化→反归一化往返一致(spk 列含 0 与常规值)"""
from action_norm import normalize_action, denormalize_action
rng = np.random.RandomState(1)
a = np.zeros((9, 18), dtype=np.float32)
a[:, :10] = rng.uniform(0, 180, (9, 10))
a[:, 10] = [0.0, 10.0, 100.0, 1000.0, 5000.0, 0.0, 10.0, 100.0, 1000.0]
a[:, 11] = [0.0, 10.0, 200.0, 2000.0, 0.0, 10.0, 200.0, 2000.0, 500.0]
rt = denormalize_action(normalize_action(a, mode="log_range"),
mode="log_range")
np.testing.assert_allclose(rt, a, rtol=1e-5, atol=1e-4)
def test_negative_log_col_clipped(self):
"""log 列负值先截 0(log10(1+x) 定义域 x>-1),不产生 NaN"""
from action_norm import normalize_action
a = np.zeros(18, dtype=np.float32)
a[10] = -50.0
a[11] = -1.0
out = normalize_action(a, mode="log_range")
self.assertEqual(float(out[10]), 0.0)
self.assertEqual(float(out[11]), 0.0)
self.assertTrue(np.all(np.isfinite(out)))
def test_dataset_log_range_mode(self):
"""dataset normalize_mode="log_range" 时目标按对数尺度归一化"""
with tempfile.TemporaryDirectory() as tmpdir:
base = Path(tmpdir)
ep_dir = base / "episode_0001"
images_dir = ep_dir / "images"
images_dir.mkdir(parents=True)
for i in range(8):
Image.fromarray(np.random.randint(
0, 255, (16, 16, 3), dtype=np.uint8)).save(
images_dir / f"{i:04d}.jpg")
actions = np.zeros((8, 12), dtype=np.float32)
actions[:, 0] = 90.0 # 舵机 0.5
actions[:, 10] = 10.0 # spk.F → log 尺度
actions[:, 11] = 10.0 # spk.delay → log 尺度
np.save(ep_dir / "actions.npy", actions)
ds = ActionDataset(tmpdir, history_len=3, action_dim=12,
image_width=16, image_height=16, frame_len=3,
normalize_actions=True,
normalize_mode="log_range")
self.assertIsNone(ds._action_mean)
self.assertIsNone(ds._action_std)
s = ds[3]
tgt = s["target"].numpy()
self.assertAlmostEqual(float(tgt[0]), 0.5, places=6)
self.assertAlmostEqual(float(tgt[10]),
float(np.log10(11.0) / np.log10(5001.0)),
places=6)
def test_config_accepts_log_range(self):
"""normalize_mode="log_range" 通过校验;非法模式拒绝"""
cfg = Config()
cfg.data.normalize_mode = "log_range"
validate_config_values(cfg.data, cfg.model)
cfg.data.normalize_mode = "log10"
with self.assertRaises(ValueError):
validate_config_values(cfg.data, cfg.model)
class TestOneHot(unittest.TestCase):
"""多头 one-hot 输出:bins.py 编解码、软标签、dataset 目标、加权 CE、
config 校验"""
# ---------------- bins.py ----------------
def test_encode_hard_onehot(self):
from bins import encode, START, BINS_PER_COL
a = np.zeros((2, 18), dtype=np.float32)
a[0, 0] = 45.0
a[0, 10] = 10.0
a[0, 11] = 200.0
a[1, 0] = 46.5
o = encode(a)
self.assertEqual(o.shape, (2, 1864))
# 每行 12 头各一个 1,其余 0
self.assertEqual(float(o.sum()), 24.0)
self.assertEqual(np.argmax(o[0, START[0]:START[1]]), 45)
self.assertEqual(np.argmax(o[1, START[0]:START[1]]), 46)
# 保留位列无头,恒 0
np.testing.assert_allclose(o[:, START[12]:], 0.0)
def test_encode_log_bins_spread_low_end(self):
"""对数箱:10Hz 与 1000Hz 落在不同箱,低端不再挤在一起"""
from bins import encode, START
a = np.zeros((2, 18), dtype=np.float32)
a[0, 10] = 10.0
a[1, 10] = 1000.0
o = encode(a)
b0 = np.argmax(o[0, START[10]:START[11]])
b1 = np.argmax(o[1, START[10]:START[11]])
self.assertNotEqual(b0, b1)
self.assertGreater(b1, b0)
def test_encode_soft_label(self):
"""软标签:真值附近多箱非零且组内概率和为 1"""
from bins import encode, START
a = np.zeros((1, 18), dtype=np.float32)
a[0, 0] = 45.0
o = encode(a, sigma=1.0)
head = o[0, START[0]:START[1]]
self.assertGreater(np.count_nonzero(head), 1)
self.assertAlmostEqual(float(head.sum()), 1.0, places=6)
# 峰值在真值箱
self.assertEqual(np.argmax(head), 45)
def test_decode_expectation(self):
"""期望值解码:与真值一致时还原箱中心;子箱分辨率由概率内插"""
from bins import encode, decode, START, BINS_PER_COL
a = np.zeros((1, 18), dtype=np.float32)
a[0, 0] = 45.0
a[0, 10] = 10.0
logits = np.log(encode(a) + 1e-8)
d = decode(logits)
self.assertAlmostEqual(float(d[0, 0]), 45.5, places=3) # 箱中心
self.assertGreater(float(d[0, 10]), 10.0) # 对数箱中心偏右
# 混合概率内插:45 箱 0.6 / 46 箱 0.4 → 45.9
# (背景须 -1e9 使尾部概率可忽略;-10 背景的尾部质量 × 高箱中心
# 会贡献 ~0.7°,属测试构造假象)
logits2 = np.full((1, 1864), -1e9, dtype=np.float32)
logits2[0, START[0] + 45] = np.log(0.6)
logits2[0, START[0] + 46] = np.log(0.4)
d2 = decode(logits2)
self.assertAlmostEqual(float(d2[0, 0]), 45.9, places=4)
def test_decode_preserves_reserved_cols_zero(self):
from bins import decode
logits = np.zeros((2, 1864), dtype=np.float32)
d = decode(logits)
np.testing.assert_allclose(d[:, 12:], 0.0)
# ---------------- dataset 链路 ----------------
def test_dataset_onehot_targets(self):
"""onehot 模式:目标为多组 one-hot,历史仍按范围归一化"""
with tempfile.TemporaryDirectory() as tmpdir:
base = Path(tmpdir)
ep_dir = base / "episode_0001"
images_dir = ep_dir / "images"
images_dir.mkdir(parents=True)
for i in range(8):
Image.fromarray(np.random.randint(
0, 255, (16, 16, 3), dtype=np.uint8)).save(
images_dir / f"{i:04d}.jpg")
actions = np.zeros((8, 12), dtype=np.float32)
actions[:, 0] = 90.0
np.save(ep_dir / "actions.npy", actions)
ds = ActionDataset(tmpdir, history_len=3, action_dim=12,
image_width=16, image_height=16, frame_len=3,
normalize_actions=True,
normalize_mode="fixed_range",
onehot=True, onehot_sigma=0.0)
s = ds[3]
tgt = s["target"]
self.assertEqual(tgt.shape, (1864,))
from bins import START
self.assertEqual(np.argmax(tgt.numpy()[START[0]:START[1]]), 90)
# 历史仍为连续归一化值(90° → 0.5)
np.testing.assert_allclose(
s["action_history"].numpy()[:, 0], 0.5, atol=1e-6)
# ---------------- 加权 CE ----------------
def test_onehot_ce(self):
from train import _onehot_ce
from bins import encode, START
logits = torch.zeros(2, 1864)
a = np.zeros((2, 18), dtype=np.float32)
a[0, 0] = 45.0
a[1, 0] = 90.0
targets = torch.from_numpy(encode(a, sigma=0.0))
w = torch.tensor([1.0] * 10 + [0.2, 0.0] + [0.0] * 6)
# 全部 12 头都预测正确 → CE≈0
logits = torch.log(targets + 1e-9)
loss = _onehot_ce(logits, targets, w, 18)
self.assertLess(float(loss), 1e-3)
# 第 2 行 head0 错箱(预测 bin 0,真值 90)→ 加权 CE 上升
bad = logits.clone()
bad[1, START[0]] = 0.0
bad[1, START[0] + 90] = float(np.log(1e-9))
loss_bad = _onehot_ce(bad, targets, w, 18)
self.assertGreater(float(loss_bad), 0.5)
# ---------------- config 校验 ----------------
def test_onehot_cli_override_applied(self):
"""--onehot CLI 标志经 apply_overrides 落到 cfg.data.onehot(回归:
曾漏映射导致静默退化为回归训练)"""
from config import build_argparser, apply_overrides
parser = build_argparser("t")
args = parser.parse_args(["--onehot", "--onehot-sigma", "2.0"])
cfg = apply_overrides(Config(), args)
self.assertTrue(cfg.data.onehot)
self.assertEqual(cfg.data.onehot_sigma, 2.0)
# 缺省不开启
args = parser.parse_args([])
cfg = apply_overrides(Config(), args)
self.assertFalse(cfg.data.onehot)
def test_dataset_sample_indices(self):
"""样本级随机划分:sample_indices 过滤样本,zscore 统计仅用子集"""
with tempfile.TemporaryDirectory() as tmpdir:
TestActionDataset._create_dummy_episode(
Path(tmpdir), n_frames=12, action_dim=4)
full = ActionDataset(tmpdir, history_len=3, action_dim=4,
image_width=32, image_height=32, frame_len=3,
normalize_actions=True)
n = len(full)
sub = ActionDataset(tmpdir, history_len=3, action_dim=4,
image_width=32, image_height=32, frame_len=3,
normalize_actions=True,
sample_indices=[0, 1, 2])
self.assertEqual(len(sub), 3)
# 原始目标一致(用非归一化口径对照,zscore 统计量不同会
# 改变归一化值,故不能直接比 target)
raw_full = ActionDataset(tmpdir, history_len=3, action_dim=4,
image_width=32, image_height=32,
frame_len=3, normalize_actions=False)
raw_sub = ActionDataset(tmpdir, history_len=3, action_dim=4,
image_width=32, image_height=32,
frame_len=3, normalize_actions=False,
sample_indices=[0, 1, 2])
for i in range(3):
np.testing.assert_allclose(
raw_sub[i]["target"].numpy(),
raw_full[i]["target"].numpy())
# 统计量由子集样本计算(zscore 均值 = 子集样本均值)
self.assertIsNotNone(sub.get_action_stats())
self.assertNotEqual(n, 3)
def test_val_split_mode_cli(self):
"""--val-split-mode 参数经 apply_overrides 落到 train 配置"""
from config import build_argparser, apply_overrides
parser = build_argparser("t")
cfg = apply_overrides(Config(),
parser.parse_args(["--val-split-mode", "episode"]))
self.assertEqual(cfg.train.val_split_mode, "episode")
cfg2 = apply_overrides(Config(), parser.parse_args([]))
self.assertEqual(cfg2.train.val_split_mode, "sample") # 新默认
def test_fixed_zero_value_still_applied(self):
"""固定 0 的列(spk.F)也必须被覆盖——掩码按权重而非常量值"""
from bins import decode, fixed_zero_columns, START
from action_norm import default_column_weights
# 构造 spk.F 头输出 1000Hz 附近 logits(垃圾预测)
logits = np.full((1, 1864), -10.0, dtype=np.float32)
logits[0, START[10] + 25] = 0.0 # 1000Hz 附近箱
raw = decode(logits, 18)
self.assertGreater(float(raw[0, 10]), 100.0) # 未覆盖时是垃圾
w = default_column_weights(18)
fixed = fixed_zero_columns(w, 18)
mask = np.asarray(w, dtype=np.float32) <= 0.0
raw[0, mask] = fixed[mask]
self.assertEqual(float(raw[0, 10]), 0.0) # 固定后归零
self.assertEqual(float(raw[0, 11]), 10.0)
def test_action_jitter(self):
"""角度抖动增强:历史加有界高斯噪声,目标保持干净"""
with tempfile.TemporaryDirectory() as tmpdir:
base = Path(tmpdir)
ep_dir = base / "episode_0001"
images_dir = ep_dir / "images"
images_dir.mkdir(parents=True)
for i in range(10):
Image.fromarray(np.random.randint(
0, 255, (16, 16, 3), dtype=np.uint8)).save(
images_dir / f"{i:04d}.jpg")
actions = np.zeros((10, 4), dtype=np.float32)
actions[:, 0] = 90.0
np.save(ep_dir / "actions.npy", actions)
# jitter=0:历史 = 原始归一化值
ds0 = ActionDataset(tmpdir, history_len=3, action_dim=4,
image_width=16, image_height=16, frame_len=3,
normalize_actions=True,
normalize_mode="fixed_range",
action_jitter=0.0)
np.testing.assert_allclose(
ds0[3]["action_history"].numpy()[:, 0], 0.5, atol=1e-6)
# jitter>0:历史围绕 0.5 抖动(0.1×180=18°→归一化 σ=0.1),
# 目标仍是干净的 0.5
np.random.seed(42)
dsj = ActionDataset(tmpdir, history_len=3, action_dim=4,
image_width=16, image_height=16, frame_len=3,
normalize_actions=True,
normalize_mode="fixed_range",
action_jitter=0.1)
hist = dsj[3]["action_history"].numpy()[:, 0]
tgt = dsj[3]["target"].numpy()[0]
self.assertGreater(float(np.std(hist)), 0.01) # 确实抖了
self.assertLess(float(np.abs(hist - 0.5).max()), 0.45) # 有界
self.assertAlmostEqual(float(tgt), 0.5, places=6) # 目标干净
# 同一索引两次采样抖动不同(每次 __getitem__ 重新加噪)
hist2 = dsj[3]["action_history"].numpy()[:, 0]
self.assertFalse(np.allclose(hist, hist2))
def test_action_jitter_cli(self):
"""--action-jitter 参数映射与校验"""
from config import build_argparser, apply_overrides
cfg = apply_overrides(Config(),
build_argparser("t").parse_args(
["--action-jitter", "0.02"]))
self.assertEqual(cfg.data.action_jitter, 0.02)
cfg.data.action_jitter = 1.5
with self.assertRaises(ValueError):
validate_config_values(cfg.data, cfg.model, cfg.train)
def test_onehot_config_validation(self):
cfg = Config()
cfg.data.onehot = True
cfg.data.normalize_actions = True
cfg.data.normalize_mode = "fixed_range"
validate_config_values(cfg.data, cfg.model, cfg.train)
# 与 predict_delta 互斥
cfg.train.predict_delta = True
with self.assertRaises(ValueError):
validate_config_values(cfg.data, cfg.model, cfg.train)
# 要求范围类归一化
cfg.train.predict_delta = False
cfg.data.normalize_mode = "zscore"
with self.assertRaises(ValueError):
validate_config_values(cfg.data, cfg.model, cfg.train)
def test_fixed_zero_columns(self):
"""权重 0 列固定常量:spk.F→0、spk.delay→10ms、保留位→0"""
from bins import fixed_zero_columns
from action_norm import default_column_weights
fixed = fixed_zero_columns(default_column_weights(18), 18)
np.testing.assert_allclose(fixed[:10], 0.0)
self.assertEqual(float(fixed[10]), 0.0)
self.assertEqual(float(fixed[11]), 10.0)
np.testing.assert_allclose(fixed[12:], 0.0)
# 全 1 权重 → 无固定列
np.testing.assert_allclose(
fixed_zero_columns(np.ones(4), 4), 0.0)
def test_onehot_model_output_shape(self):
"""onehot 模型前向输出 TOTAL_BINS logits"""
cfg = Config()
cfg.data.onehot = True
cfg.data.normalize_mode = "fixed_range"
cfg.data.image_width = 32
cfg.data.image_height = 32
m = ActionPredictor(cfg.model, cfg.data, pretrained=False)
from bins import TOTAL_BINS
images = torch.randn(2, cfg.data.frame_len, 3, 32, 32)
history = torch.randn(2, cfg.data.history_len, cfg.data.action_dim)
out = m(images, history)
self.assertEqual(out.shape, (2, TOTAL_BINS))
class TestR2ImuModel(unittest.TestCase): class TestR2ImuModel(unittest.TestCase):
"""R2 方案一模型:独立 IMU token 注入(序列 F+H+2)""" """R2 方案一模型:独立 IMU token 注入(序列 F+H+2)"""
+167 -37
View File
@@ -8,8 +8,12 @@ from tqdm import tqdm
from config import (Config, build_argparser, apply_overrides, from config import (Config, build_argparser, apply_overrides,
validate_config_values, warn_init_from_mismatch) validate_config_values, warn_init_from_mismatch)
from action_norm import normalize_action, is_range_mode
from bins import (encode as bin_encode, decode as bin_decode,
fixed_zero_columns, START, HEAD_COLS)
from model import ActionPredictor from model import ActionPredictor
from dataset import ActionDataset from dataset import ActionDataset
from action_norm import default_column_weights
def split_episodes(data_dir: str, val_ratio: float, seed: int = 42): def split_episodes(data_dir: str, val_ratio: float, seed: int = 42):
@@ -21,6 +25,17 @@ def split_episodes(data_dir: str, val_ratio: float, seed: int = 42):
return indices[val_count:], indices[:val_count] return indices[val_count:], indices[:val_count]
def _weighted_mse(pred: torch.Tensor, target: torch.Tensor,
weights: torch.Tensor = None) -> torch.Tensor:
"""逐列加权 MSE:mean(w·(pred-target)²)/mean(w),weights None 时退化为普通 MSE。
除以 mean(w) 保持损失量级与未加权口径可比(w 全 1 时与普通 MSE 完全一致)。
"""
if weights is None:
return torch.nn.functional.mse_loss(pred, target)
return (weights * (pred - target) ** 2).mean() / weights.mean()
def _collate(batch): def _collate(batch):
"""多步样本({"steps":[...]})逐 k 聚合成 batch 列表;单步样本走默认 collate。 """多步样本({"steps":[...]})逐 k 聚合成 batch 列表;单步样本走默认 collate。
@@ -36,6 +51,28 @@ def _collate(batch):
return default_collate(batch) return default_collate(batch)
def _onehot_ce(logits: torch.Tensor, targets: torch.Tensor,
weights: torch.Tensor, action_dim: int) -> torch.Tensor:
"""多头加权交叉熵:每组 softmax 与 one-hot(或软标签)求 CE,按列权重加权
logits/targets: (B, TOTAL_BINS);weights: (action_dim,) 列权重向量。
软标签(sigma>0)各组概率和恒 1,CE 定义一致。
"""
ls = None
head_ce = []
for c in HEAD_COLS:
if c >= action_dim:
break
s, e = START[c], START[c + 1]
# 逐头 log_softmax(不能整行:各头 bin 数不同,且整行 max 会
# 把其他头压到 -1e9 量级,CE 爆炸)
head_ce.append(-(targets[:, s:e]
* torch.log_softmax(logits[:, s:e], dim=-1)).sum(-1))
head_ce = torch.stack(head_ce, dim=1) # (B, n_heads)
w = weights[:head_ce.size(1)]
return (head_ce * w).sum() / (w.sum() * head_ce.size(0))
def _shift_history(history: torch.Tensor, new_action: torch.Tensor) -> torch.Tensor: def _shift_history(history: torch.Tensor, new_action: torch.Tensor) -> torch.Tensor:
"""动作历史左移一步、追加 new_action 到末尾(scheduled sampling 回传自预测用)。 """动作历史左移一步、追加 new_action 到末尾(scheduled sampling 回传自预测用)。
@@ -46,7 +83,9 @@ def _shift_history(history: torch.Tensor, new_action: torch.Tensor) -> torch.Ten
def train_one_epoch(model, loader, optimizer, device, max_grad_norm: float = None, def train_one_epoch(model, loader, optimizer, device, max_grad_norm: float = None,
rollout_len: int = 1, ss_prob: float = 1.0, rollout_len: int = 1, ss_prob: float = 1.0,
predict_delta: bool = False): predict_delta: bool = False, weights: torch.Tensor = None,
onehot: bool = False, range_mode: str = None,
action_dim: int = 18):
model.train() model.train()
total_loss = 0.0 total_loss = 0.0
count = 0 count = 0
@@ -59,7 +98,8 @@ def train_one_epoch(model, loader, optimizer, device, max_grad_norm: float = Non
target = batch["target"].to(device) target = batch["target"].to(device)
imu = batch["imu"].to(device) if "imu" in batch else None imu = batch["imu"].to(device) if "imu" in batch else None
pred = model(images, history, imu) pred = model(images, history, imu)
loss = torch.nn.functional.mse_loss(pred, target) loss = (_onehot_ce(pred, target, weights, action_dim) if onehot
else _weighted_mse(pred, target, weights))
bs = images.size(0) bs = images.size(0)
else: else:
# 多步 scheduled sampling:首步必用真历史;后续步以 ss_prob 用真历史、 # 多步 scheduled sampling:首步必用真历史;后续步以 ss_prob 用真历史、
@@ -77,11 +117,26 @@ def train_one_epoch(model, loader, optimizer, device, max_grad_norm: float = Non
history = true_history history = true_history
# 否则沿用上一轮回传的 history(自预测构造) # 否则沿用上一轮回传的 history(自预测构造)
pred = model(images, history, imu) pred = model(images, history, imu)
loss = loss + torch.nn.functional.mse_loss(pred, target) loss = loss + (_onehot_ce(pred, target, weights, action_dim)
if onehot
else _weighted_mse(pred, target, weights))
bs = images.size(0) bs = images.size(0)
# 回传:残差模式先把 Δ̂ 加回 a_{t-1} 得到绝对动作,再移入历史 # 回传:onehot 先期望值解码(原始空间)再按范围归一化;
# 残差模式把 Δ̂ 加回 a_{t-1} 得到绝对动作;再移入历史
with torch.no_grad(): with torch.no_grad():
abs_action = prev_action + pred if predict_delta else pred if onehot:
raw = bin_decode(pred.detach().cpu().numpy(),
action_dim)
# 权重 0 列固定为常量,防自预测垃圾进入历史
fixed = fixed_zero_columns(
weights.cpu().numpy(), action_dim)
w = weights.cpu().numpy()[:action_dim]
raw[w <= 0.0] = fixed[w <= 0.0]
abs_action = torch.from_numpy(normalize_action(
raw, action_dim, mode=range_mode)).to(device)
else:
abs_action = (prev_action + pred
if predict_delta else pred)
history = _shift_history(true_history, abs_action.detach()) history = _shift_history(true_history, abs_action.detach())
loss = loss / len(batch["steps"]) loss = loss / len(batch["steps"])
loss.backward() loss.backward()
@@ -94,7 +149,9 @@ def train_one_epoch(model, loader, optimizer, device, max_grad_norm: float = Non
@torch.no_grad() @torch.no_grad()
def validate(model, loader, device, rollout_len: int = 1, predict_delta: bool = False): def validate(model, loader, device, rollout_len: int = 1, predict_delta: bool = False,
weights: torch.Tensor = None, onehot: bool = False,
action_dim: int = 18):
model.eval() model.eval()
total_loss = 0.0 total_loss = 0.0
count = 0 count = 0
@@ -105,7 +162,8 @@ def validate(model, loader, device, rollout_len: int = 1, predict_delta: bool =
target = batch["target"].to(device) target = batch["target"].to(device)
imu = batch["imu"].to(device) if "imu" in batch else None imu = batch["imu"].to(device) if "imu" in batch else None
pred = model(images, history, imu) pred = model(images, history, imu)
loss = torch.nn.functional.mse_loss(pred, target) loss = (_onehot_ce(pred, target, weights, action_dim) if onehot
else _weighted_mse(pred, target, weights))
bs = images.size(0) bs = images.size(0)
else: else:
# 验证固定全真历史(确定性,不带 scheduled sampling 随机性), # 验证固定全真历史(确定性,不带 scheduled sampling 随机性),
@@ -118,7 +176,9 @@ def validate(model, loader, device, rollout_len: int = 1, predict_delta: bool =
target = step["target"].to(device) target = step["target"].to(device)
imu = step["imu"].to(device) if "imu" in step else None imu = step["imu"].to(device) if "imu" in step else None
pred = model(images, history, imu) pred = model(images, history, imu)
loss = loss + torch.nn.functional.mse_loss(pred, target) loss = loss + (_onehot_ce(pred, target, weights, action_dim)
if onehot
else _weighted_mse(pred, target, weights))
bs = images.size(0) bs = images.size(0)
loss = loss / len(batch["steps"]) loss = loss / len(batch["steps"])
total_loss += loss.item() * bs total_loss += loss.item() * bs
@@ -146,30 +206,64 @@ def main():
device = torch.device("cuda" if torch.cuda.is_available() else "cpu") device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Device: {device}") print(f"Device: {device}")
train_indices, val_indices = split_episodes( if cfg.train.val_split_mode == "sample":
cfg.data.data_dir, cfg.train.val_split, seed=cfg.train.seed) # 样本级随机划分:先建全量数据集取样本索引,再按 seed 随机分成
print(f"Episodes: {len(train_indices)} train, {len(val_indices)} val") # train/val(zscore 统计量仍只由训练样本计算,经 stats 文件复用)
probe_ds = ActionDataset(
cfg.data.data_dir, cfg.data.history_len, cfg.data.action_dim,
cfg.data.image_width, cfg.data.image_height, False,
cfg.data.action_stats_path, None, cfg.data.frame_len,
predict_delta=cfg.train.predict_delta,
rollout_len=cfg.train.rollout_len, imu_dim=cfg.data.imu_dim,
normalize_mode=cfg.data.normalize_mode)
n = len(probe_ds)
rng = np.random.RandomState(cfg.train.seed)
perm = rng.permutation(n)
val_count = max(1, int(n * cfg.train.val_split))
val_idx = perm[:val_count]
train_idx = perm[val_count:]
train_indices = None
val_indices = None
print(f"Sample split: {n - val_count} train, {val_count} val "
f"(mode=sample)")
else:
train_indices, val_indices = split_episodes(
cfg.data.data_dir, cfg.train.val_split, seed=cfg.train.seed)
print(f"Episodes: {len(train_indices)} train, {len(val_indices)} val")
train_ds = ActionDataset( train_ds = ActionDataset(
cfg.data.data_dir, cfg.data.history_len, cfg.data.action_dim, cfg.data.data_dir, cfg.data.history_len, cfg.data.action_dim,
cfg.data.image_width, cfg.data.image_height, cfg.data.normalize_actions, cfg.data.image_width, cfg.data.image_height, cfg.data.normalize_actions,
cfg.data.action_stats_path, train_indices, cfg.data.frame_len, cfg.data.action_stats_path, train_indices, cfg.data.frame_len,
sample_indices=train_idx if cfg.train.val_split_mode == "sample" else None,
augment=cfg.train.augment, augment=cfg.train.augment,
predict_delta=cfg.train.predict_delta, rollout_len=cfg.train.rollout_len, predict_delta=cfg.train.predict_delta, rollout_len=cfg.train.rollout_len,
imu_dim=cfg.data.imu_dim) imu_dim=cfg.data.imu_dim, normalize_mode=cfg.data.normalize_mode,
if cfg.data.normalize_actions and not cfg.data.action_stats_path: onehot=cfg.data.onehot, onehot_sigma=cfg.data.onehot_sigma,
action_jitter=cfg.data.action_jitter)
auto_stats = False
if (cfg.data.normalize_actions and cfg.data.normalize_mode == "zscore"
and not cfg.data.action_stats_path):
# 归一化统计量必须只用训练集计算,再供验证集复用,否则 val loss 不可信 # 归一化统计量必须只用训练集计算,再供验证集复用,否则 val loss 不可信
# (fixed_range 模式免 stats 文件,跳过此段)
stats_path = os.path.join(cfg.train.checkpoint_dir, "action_stats.npy") stats_path = os.path.join(cfg.train.checkpoint_dir, "action_stats.npy")
np.save(stats_path, train_ds.get_action_stats()) np.save(stats_path, train_ds.get_action_stats())
print(f"Action stats saved to: {stats_path}") print(f"Action stats saved to: {stats_path}")
# 只存裸文件名:推理端按 checkpoint 目录解析相对路径 # 验证集构建需要完整路径(相对路径按 cwd 解析会找不到文件)
cfg.data.action_stats_path = "action_stats.npy" cfg.data.action_stats_path = stats_path
auto_stats = True
val_ds = ActionDataset( val_ds = ActionDataset(
cfg.data.data_dir, cfg.data.history_len, cfg.data.action_dim, cfg.data.data_dir, cfg.data.history_len, cfg.data.action_dim,
cfg.data.image_width, cfg.data.image_height, cfg.data.normalize_actions, cfg.data.image_width, cfg.data.image_height, cfg.data.normalize_actions,
cfg.data.action_stats_path, val_indices, cfg.data.frame_len, cfg.data.action_stats_path, val_indices, cfg.data.frame_len,
sample_indices=val_idx if cfg.train.val_split_mode == "sample" else None,
predict_delta=cfg.train.predict_delta, rollout_len=cfg.train.rollout_len, predict_delta=cfg.train.predict_delta, rollout_len=cfg.train.rollout_len,
imu_dim=cfg.data.imu_dim) imu_dim=cfg.data.imu_dim, normalize_mode=cfg.data.normalize_mode,
onehot=cfg.data.onehot, onehot_sigma=cfg.data.onehot_sigma,
action_jitter=cfg.data.action_jitter)
if auto_stats:
# checkpoint 配置只存裸文件名:推理端按 checkpoint 目录解析相对路径
cfg.data.action_stats_path = "action_stats.npy"
print(f"Samples: {len(train_ds)} train, {len(val_ds)} val") print(f"Samples: {len(train_ds)} train, {len(val_ds)} val")
if len(train_ds) == 0: if len(train_ds) == 0:
@@ -193,6 +287,21 @@ def main():
n_params = sum(p.numel() for p in model.parameters()) n_params = sum(p.numel() for p in model.parameters())
print(f"Model params: {n_params:,}") print(f"Model params: {n_params:,}")
# 逐列损失权重(仅训练/验证损失用,推理端无感知)
col_w = cfg.data.column_weights
if col_w is None:
col_w = default_column_weights(cfg.data.action_dim)
weights_t = torch.tensor(np.asarray(col_w, dtype=np.float32), device=device)
print(f"Column weights: {[round(float(v), 4) for v in weights_t.tolist()]}")
range_mode = (cfg.data.normalize_mode
if (cfg.data.normalize_actions
and is_range_mode(cfg.data.normalize_mode))
else None)
if cfg.data.onehot:
print(f"One-hot 输出: {sum(1 for c in HEAD_COLS if c < cfg.data.action_dim)} 头"
f" / {sum(START[c + 1] - START[c] for c in HEAD_COLS if c < cfg.data.action_dim)} bins"
f"(sigma={cfg.data.onehot_sigma})")
if args.init_from: if args.init_from:
init_ckpt = torch.load(args.init_from, map_location="cpu", weights_only=True) init_ckpt = torch.load(args.init_from, map_location="cpu", weights_only=True)
warn_init_from_mismatch(init_ckpt.get("config", {}), cfg) warn_init_from_mismatch(init_ckpt.get("config", {}), cfg)
@@ -219,6 +328,9 @@ def main():
model.load_state_dict(resume_ckpt["model_state_dict"]) model.load_state_dict(resume_ckpt["model_state_dict"])
optimizer.load_state_dict(resume_ckpt["optimizer_state_dict"]) optimizer.load_state_dict(resume_ckpt["optimizer_state_dict"])
scheduler.load_state_dict(resume_ckpt["scheduler_state_dict"]) scheduler.load_state_dict(resume_ckpt["scheduler_state_dict"])
# 注意:scheduler 状态(含 T_max)随 checkpoint 恢复,LR 余弦曲线
# 按原调度继续;resume 时改 --epochs 只影响循环边界,不重塑 LR
# 曲线(如需新曲线请重新训练,勿 resume)
start_epoch = resume_ckpt["epoch"] + 1 start_epoch = resume_ckpt["epoch"] + 1
best_val_loss = resume_ckpt.get("best_val_loss", float("inf")) best_val_loss = resume_ckpt.get("best_val_loss", float("inf"))
patience_counter = resume_ckpt.get("patience_counter", 0) patience_counter = resume_ckpt.get("patience_counter", 0)
@@ -227,33 +339,42 @@ def main():
for epoch in range(start_epoch, cfg.train.epochs + 1): for epoch in range(start_epoch, cfg.train.epochs + 1):
print(f"\nEpoch {epoch}/{cfg.train.epochs} lr={optimizer.param_groups[0]['lr']:.6f}") print(f"\nEpoch {epoch}/{cfg.train.epochs} lr={optimizer.param_groups[0]['lr']:.6f}")
train_loss = train_one_epoch(model, train_loader, optimizer, device, train_loss = train_one_epoch(
cfg.train.max_grad_norm, model, train_loader, optimizer, device,
rollout_len=cfg.train.rollout_len, cfg.train.max_grad_norm,
ss_prob=cfg.train.ss_prob, rollout_len=cfg.train.rollout_len,
predict_delta=cfg.train.predict_delta) ss_prob=cfg.train.ss_prob,
predict_delta=cfg.train.predict_delta,
weights=weights_t, onehot=cfg.data.onehot,
range_mode=range_mode, action_dim=cfg.data.action_dim)
val_loss = validate(model, val_loader, device, val_loss = validate(model, val_loader, device,
rollout_len=cfg.train.rollout_len, rollout_len=cfg.train.rollout_len,
predict_delta=cfg.train.predict_delta) predict_delta=cfg.train.predict_delta,
weights=weights_t, onehot=cfg.data.onehot,
action_dim=cfg.data.action_dim)
scheduler.step() scheduler.step()
print(f" train_loss={train_loss:.6f} val_loss={val_loss:.6f}") print(f" train_loss={train_loss:.6f} val_loss={val_loss:.6f}")
if val_loss < best_val_loss: if val_loss < best_val_loss:
best_val_loss = val_loss if not np.isfinite(val_loss):
patience_counter = 0 print(f"WARNING: val_loss 非有限值({val_loss}),跳过 best 更新")
torch.save({ patience_counter += 1
"epoch": epoch, else:
"model_state_dict": model.state_dict(), best_val_loss = val_loss
"optimizer_state_dict": optimizer.state_dict(), patience_counter = 0
"val_loss": val_loss, torch.save({
"config": cfg.to_dict(), "epoch": epoch,
}, best_path) "model_state_dict": model.state_dict(),
inference_path = os.path.join(cfg.train.checkpoint_dir, "best_inference.pt") "optimizer_state_dict": optimizer.state_dict(),
torch.save({ "val_loss": val_loss,
"model_state_dict": model.state_dict(), "config": cfg.to_dict(),
"config": cfg.to_dict(), }, best_path)
}, inference_path) inference_path = os.path.join(cfg.train.checkpoint_dir, "best_inference.pt")
print(f" -> saved best (val_loss={val_loss:.6f})") torch.save({
"model_state_dict": model.state_dict(),
"config": cfg.to_dict(),
}, inference_path)
print(f" -> saved best (val_loss={val_loss:.6f})")
else: else:
patience_counter += 1 patience_counter += 1
if patience_counter >= cfg.train.patience: if patience_counter >= cfg.train.patience:
@@ -279,6 +400,15 @@ def main():
"model_state_dict": model.state_dict(), "model_state_dict": model.state_dict(),
"config": cfg.to_dict(), "config": cfg.to_dict(),
}, os.path.join(cfg.train.checkpoint_dir, "best_inference.pt")) }, os.path.join(cfg.train.checkpoint_dir, "best_inference.pt"))
torch.save({
"epoch": epoch,
"model_state_dict": model.state_dict(),
"optimizer_state_dict": optimizer.state_dict(),
"scheduler_state_dict": scheduler.state_dict(),
"best_val_loss": best_val_loss,
"patience_counter": patience_counter,
"config": cfg.to_dict(),
}, os.path.join(cfg.train.checkpoint_dir, "best.pt"))
print("WARNING: saved final weights as best_inference.pt (no valid validation loss)") print("WARNING: saved final weights as best_inference.pt (no valid validation loss)")
else: else:
print(f"Best model saved to: {best_path}") print(f"Best model saved to: {best_path}")
+16
View File
@@ -0,0 +1,16 @@
"""actpred 共享工具函数(无 torch 依赖,推理/部署文件可复用)"""
import numpy as np
def parse_action_history(s: str, action_dim: int) -> np.ndarray:
"""解析冒号分隔的动作历史字符串为 (H, D) float32 数组"""
steps = s.split(":")
history = []
for step in steps:
values = [float(v) for v in step.split(",")]
if len(values) != action_dim:
raise ValueError(
f"Expected {action_dim} values per step, got {len(values)}")
history.append(values)
return np.array(history, dtype=np.float32)
+216 -27
View File
@@ -17,12 +17,14 @@
- 🧬 **智能体自我进化**:通过 `update_soul` / `update_identity` 工具动态更新角色定位与用户画像 - 🧬 **智能体自我进化**:通过 `update_soul` / `update_identity` 工具动态更新角色定位与用户画像
- 🤖 **子代理**:通过 `create_subagent` 工具派生出拥有独立上下文/角色设定的轻量子代理,主代理可分解长任务、隔离上下文,子代理可选用不同模型 - 🤖 **子代理**:通过 `create_subagent` 工具派生出拥有独立上下文/角色设定的轻量子代理,主代理可分解长任务、隔离上下文,子代理可选用不同模型
- 🔄 **技能热重载**:无需重启即可添加、修改或删除扩展技能 - 🔄 **技能热重载**:无需重启即可添加、修改或删除扩展技能
- 🔐 **SOCKS5 代理**:通过 `set_proxy` / `get_proxy` 工具为网页访问与 API 调用配置代理,可持久化 - 🔐 **SOCKS5 代理**:通过 `set_proxy` / `get_proxy` 工具为网页访问与 API 调用配置代理,可持久化;密码明文仅存内存不落盘,跨重启经 `password_env` 环境变量注入(对齐 `api_key_env` 惯例)
- 💾 **对话管理**:历史对话归档、开始新对话(重置 AI 记忆并重新加载提示词) - 💾 **对话管理**:历史对话归档、开始新对话(重置 AI 记忆并重新加载提示词)
- 📦 **对话压缩**:消息数或 Token 数达到阈值时自动压缩,保持上下文质量 - 📦 **对话压缩**:消息数或 Token 数达到阈值时自动压缩,保持上下文质量
- 🛡️ **内存安全防护**:文件读取按需加载不全载、命令输出 1MB 限制、单行超长截断、工具结果多层防护防止 OOM - 🛡️ **内存安全防护**:文件读取按需加载不全载、命令输出 1MB 限制、单行超长截断、工具结果多层防护防止 OOM
- 🔒 **错误自动恢复**:API 请求失败自动回滚消息,工具结果安全清洗(控制字符过滤、非字符串转换) - 🔒 **错误自动恢复**:API 请求失败自动回滚消息,工具结果安全清洗(控制字符过滤、非字符串转换)
- 🛑 **可靠中断机制**:支持 Ctrl+C 实时中断,通过 `threading.Event` + HTTP 连接关闭 + 子进程 kill 三层取消,流式响应和长时间运行命令均可立即终止 - 🛑 **可靠中断机制**:支持 Ctrl+C 实时中断,通过 `threading.Event` + HTTP 连接关闭 + 子进程 kill 三层取消,流式响应和长时间运行命令均可立即终止
- 💻 **CLI 一次性对话**:`--once "问题"` 不启动 WebSocket 直接执行并退出,stdout/stderr 分流可管道组合,支持 `--quiet` / `--show-reasoning` / `--worker-only` / `--model` / `--temperature` / `--once-timeout`
- 🔀 **多模型档案**:`config.json` 的 `models` 节可配置多个 OpenAI Chat Completions 兼容端点(DeepSeek/Kimi/Qwen/本地 Ollama 等),按名称切换,档案级 api_url/model/api_key_env/reasoning_enabled/timeout/temperature/display_name;`--model` 支持 `model:`/`profile:` 前缀逃逸
## 🚀 快速开始 ## 🚀 快速开始
@@ -55,13 +57,28 @@ python aibox.py
启动后,WebSocket 服务器将运行在 `ws://localhost:8765`(地址与端口可在 `config.json` 的 `system.websocket_host` / `system.websocket_port` 中修改)。 启动后,WebSocket 服务器将运行在 `ws://localhost:8765`(地址与端口可在 `config.json` 的 `system.websocket_host` / `system.websocket_port` 中修改)。
> 注意:本模块入口文件为 `aibox.py`(当前约 3706 行,集成 WebSocket 服务器、AI 引擎、工具/技能系统、任务调度器于一体;自 v3.0 起工具类与子系统已抽离至 `tools/`、`subsystems/`、`common/`、`tests/` 包,aibox.py 仅保留核心类与编排逻辑)。`client.py` 为示例 WebSocket 客户端,`voice_client.py` 为语音客户端,`llm_client.py` 为多厂商大模型客户端抽象层,`_run.sh` 为被 `core` 拉起的启动脚本。 ### 管理大模型接入点(不用手写配置)
不想手写 `config.json` 的 `models` 节?用交互式菜单工具:
```bash
python model_config.py # 交互式菜单:列出 / 添加 / 删除 / 连通性验证
python model_config.py --list # 非交互:列出当前接入点
```
- **添加接入点**:按提示填写档案名、API 地址(附常见厂商端点参考)、模型名;Key 两种方式——环境变量名(推荐,Key 不落盘)或免密本地端点(Ollama 等);可选显示名/温度/超时/思考模式/失败转移(从已有接入点多选)/设为默认
- **删除接入点**:编号选择 + 确认,自动清理其他档案的失败转移引用
- **连通性验证**:对选中接入点发一次最小请求,直接给出成败原因
- 添加后可立即使用:`python aibox.py --model <档案名>` 指定,或设为默认后无需 `--model`
> 注意:本模块入口文件为 `aibox.py`(当前约 3408 行,集成 WebSocket 服务器、AI 引擎、工具/技能系统、任务调度器于一体;自 v3.0 起工具类与子系统已抽离至 `tools/`、`subsystems/`、`common/`、`tests/` 包,aibox.py 仅保留核心类与编排逻辑)。`client.py` 为示例 WebSocket 客户端,`voice_client.py` 为语音客户端,`llm_client.py` 为多厂商大模型客户端抽象层,`_run.sh` 为被 `core` 拉起的启动脚本。
## 📁 目录结构 ## 📁 目录结构
``` ```
aibox/ aibox/
├── aibox.py # 服务主程序(WebSocket 服务器 + AI 引擎 + 任务调度器 + 核心类 DeepSeekChat/WebSocketHandler/EnhancedAIManager) ├── aibox.py # 服务主程序(WebSocket 服务器 + AI 引擎 + 任务调度器 + 核心类 DeepSeekChat/WebSocketHandler/EnhancedAIManager)
├── model_config.py # 大模型接入点交互式管理工具(列表/添加/删除/连通性验证,不用手写配置)
├── client.py # 示例 WebSocket 客户端 ├── client.py # 示例 WebSocket 客户端
├── voice_client.py # 语音客户端 ├── voice_client.py # 语音客户端
├── llm_client.py # 大模型客户端抽象层(多厂商切换,DeepSeekClient 兼容 OpenAI 协议) ├── llm_client.py # 大模型客户端抽象层(多厂商切换,DeepSeekClient 兼容 OpenAI 协议)
@@ -73,11 +90,13 @@ aibox/
│ ├── constants.py # 常量定义 │ ├── constants.py # 常量定义
│ ├── i18n.py # 多语言 │ ├── i18n.py # 多语言
│ └── logger.py # 日志器 │ └── logger.py # 日志器
├── subsystems/ # 子系统包(Wave 1:从 aibox.py 抽离的基础设施) ├── subsystems/ # 子系统包(Wave 1/2:从 aibox.py 抽离的基础设施)
│ ├── executors.py # CommandExecutor + SubprocessCommandExecutor │ ├── executors.py # CommandExecutor + SubprocessCommandExecutor
│ ├── prompt_manager.py # PromptManager(单例,提示词按数字前缀排序加载) │ ├── prompt_manager.py # PromptManager(单例,提示词按数字前缀排序加载)
│ ├── db.py # MemoryDatabase + MemoDatabase(SQLite) │ ├── db.py # MemoryDatabase + MemoDatabase(SQLite,FTS5 trigram 中文检索 + LIKE 兜底)
│ └── skill_manager.py # SkillManager(技能发现/加载/热重载) │ ├── skill_manager.py # SkillManager(技能发现/加载/热重载)
│ ├── conversation_history.py # ConversationHistory(对话历史管理)
│ └── conversation_compressor.py # ConversationCompressor(对话压缩,注入 llm_client 解耦)
├── tools/ # 工具系统包(A 批 + B 批共 17 个工具类) ├── tools/ # 工具系统包(A 批 + B 批共 17 个工具类)
│ ├── base.py # Tool 抽象基类 │ ├── base.py # Tool 抽象基类
│ ├── registry.py # ToolRegistry 工具注册器 │ ├── registry.py # ToolRegistry 工具注册器
@@ -88,14 +107,24 @@ aibox/
│ ├── prompt_tools.py # B 批:UpdateIdentity/UpdateSoul/SaveMemoryAndEndConversation │ ├── prompt_tools.py # B 批:UpdateIdentity/UpdateSoul/SaveMemoryAndEndConversation
│ ├── proxy_tools.py # B 批:SetProxy/GetProxy │ ├── proxy_tools.py # B 批:SetProxy/GetProxy
│ └── misc_tools.py # B 批:AskUserTool │ └── misc_tools.py # B 批:AskUserTool
├── tests/ # 单元测试套件(171 项全过) ├── tests/ # 单元测试套件(441 项全过)
│ ├── conftest.py # sys.path 配置 + websockets 桩 │ ├── conftest.py # sys.path 配置 + websockets 桩
│ ├── test_basic_tools.py # A 批工具 43 项 │ ├── test_basic_tools.py # A 批工具 43 项
│ ├── test_b_tools.py # B 批工具 58 项 │ ├── test_b_tools.py # B 批工具 58 项
│ ├── test_executors.py # 命令执行器 │ ├── test_executors.py # 命令执行器
│ ├── test_prompt_manager.py # 提示词管理器 │ ├── test_prompt_manager.py # 提示词管理器
│ ├── test_db.py # 记忆/备忘录数据库 │ ├── test_db.py # 记忆/备忘录数据库(含中文检索/迁移)
│ └── test_skill_manager.py # 技能管理器 │ ├── test_conversation_history.py # 对话历史
│ ├── test_conversation_compressor.py # 对话压缩器
│ ├── test_skill_manager.py # 技能管理器
│ ├── test_once_mode.py # CLI 一次性对话模式(Batch A/B/C 15 项)
│ ├── test_models_profile.py # 多模型档案(Batch D 17 项)
│ ├── test_config.py # 配置脱敏(F1 3 项)
│ ├── test_f2_fixes.py # 超时清理/线程收尾(F2 5 项)
│ ├── test_g_fixes.py # 第三轮审计 Batch G(20 项)
│ ├── test_h_fixes.py # 第四轮审计 Batch H(26 项)
│ ├── test_i_fixes.py # 遗留收尾 Batch I(16 项)
│ └── test_model_config.py # 模型接入点管理工具(17 项)
├── skills/ # 内置扩展技能(*.py 可执行脚本 / *.md Markdown 技能) ├── skills/ # 内置扩展技能(*.py 可执行脚本 / *.md Markdown 技能)
│ ├── hello-world.py │ ├── hello-world.py
│ ├── aes-crypto.py │ ├── aes-crypto.py
@@ -130,7 +159,7 @@ common ← subsystems ← tools ← aibox.py
```bash ```bash
# 从仓库根目录运行 # 从仓库根目录运行
py -m pytest aibox/tests -q py -m pytest aibox/tests -q
# 当前:171 项全过,约 11s # 当前:264 项全过
``` ```
运行期数据存放于 `~/.aibox/`: 运行期数据存放于 `~/.aibox/`:
@@ -182,7 +211,7 @@ py -m pytest aibox/tests -q
| `memory` | 保存、搜索(支持分页)、删除记忆 | | `memory` | 保存、搜索(支持分页)、删除记忆 |
| `memo` | 创建、查看、完成、搜索任务 | | `memo` | 创建、查看、完成、搜索任务 |
| `express_emotion` | 表达情绪并记录当前情绪状态(anger/joy/sadness/surprise),可指定 `persona` 从 `~/.aibox/skills/` 加载人格技能(如 angry/coquettish/tsundere)派生携带该人格的情绪子代理,保证人格独立、不污染主对话上下文 | | `express_emotion` | 表达情绪并记录当前情绪状态(anger/joy/sadness/surprise),可指定 `persona` 从 `~/.aibox/skills/` 加载人格技能(如 angry/coquettish/tsundere)派生携带该人格的情绪子代理,保证人格独立、不污染主对话上下文 |
| `create_subagent` | 派生子代理:独立上下文/角色设定,仅拥有执行面工具(无记忆/人格/派生/提问能力),可选独立模型,用于长任务分解、上下文隔离、独立角色专注处理 | | `create_subagent` | 派生子代理:独立上下文/角色设定,仅拥有执行面工具(无记忆/人格/派生/提问能力),可选独立模型(工具描述动态列出 `models` 节可用档案名,防 LLM 编造名称),用于长任务分解、上下文隔离、独立角色专注处理 |
| `search_skills` | 搜索扩展技能(支持 Markdown 和可执行技能),查到后派生子代理执行 | | `search_skills` | 搜索扩展技能(支持 Markdown 和可执行技能),查到后派生子代理执行 |
| `reload_skills` | 热重载扩展技能 | | `reload_skills` | 热重载扩展技能 |
| `create_skill` | 创建新技能(AI 自主编写 Python 脚本,实际写入经子代理完成) | | `create_skill` | 创建新技能(AI 自主编写 Python 脚本,实际写入经子代理完成) |
@@ -192,7 +221,7 @@ py -m pytest aibox/tests -q
| `update_soul` | 更新 AI 自我定义(`99_SOUL.md`,智能体进化) | | `update_soul` | 更新 AI 自我定义(`99_SOUL.md`,智能体进化) |
| `save_memory_and_end_conversation` | 保存记忆并结束对话 | | `save_memory_and_end_conversation` | 保存记忆并结束对话 |
| `get_current_time` | 获取当前时间 | | `get_current_time` | 获取当前时间 |
| `set_proxy` | 设置 SOCKS5 代理(host/port/可选账号密码,可持久化,即时生效) | | `set_proxy` | 设置 SOCKS5 代理(host/port/可选账号密码,可持久化,即时生效);`password` 明文仅本次会话有效不落盘,跨重启用 `password_env` 指定存有密码的环境变量名 |
| `get_proxy` | 查看当前 SOCKS5 代理配置 | | `get_proxy` | 查看当前 SOCKS5 代理配置 |
### 子代理(worker)工具集 ### 子代理(worker)工具集
@@ -498,15 +527,69 @@ AI 可以通过 `update_soul` 工具动态更新 `99_SOUL.md`,通过 `update_i
"broadcast_enabled": true "broadcast_enabled": true
}, },
"api": { "api": {
"provider": "deepseek", "provider": "openai_compatible",
"deepseek_api_url": "https://api.deepseek.com/chat/completions", "deepseek_api_url": "https://api.deepseek.com/chat/completions",
"deepseek_model": "deepseek-v4-flash", "deepseek_model": "deepseek-v4-flash",
"reasoning_enabled": false, "reasoning_enabled": false,
"api_key_env": "DEEPSEEK_API_KEY", "api_key_env": "DEEPSEEK_API_KEY",
"timeout": 120, "timeout": 120,
"temperature": 0.7 "temperature": 0.7,
"default_model": "deepseek"
}, },
"context": { "models": {},
```
### 🔀 多模型档案(models 节)
`models` 可配置多个 OpenAI Chat Completions 协议兼容端点,按**档案名**切换(v3.2):
```json
{
"models": {
"kimi": {
"api_url": "https://api.moonshot.cn/v1/chat/completions",
"model": "kimi-k2",
"api_key_env": "MOONSHOT_API_KEY",
"reasoning_enabled": false,
"timeout": 120
},
"local": {
"api_url": "http://localhost:11434/v1/chat/completions",
"model": "qwen3:32b",
"api_key_env": ""
}
},
"api": { "default_model": "kimi" }
}
```
**档案字段**:
| 字段 | 说明 |
|---|---|
| `api_url` | **必填**。端点 URL(OpenAI Chat Completions 协议) |
| `model` | **必填**(除非调用时传裸模型名)。该档案使用的模型名 |
| `api_key_env` | API Key 环境变量名;**未配置回退 `api.api_key_env`;显式空串 `""` 为本地免密端点**(Ollama/vLLM)。档案端点环境变量取不到时**直接报错,绝不回退 `~/.cert/deepseek.key`**——该文件为 DeepSeek 专属凭据,跨厂商复用属于泄漏(key 文件回退仅对无档案的旧 `api` 扁平节生效) |
| `reasoning_enabled` | 档案级思考模式开关(缺省回退 `api.reasoning_enabled`) |
| `timeout` | 请求超时秒数 |
| `temperature` | 档案级默认采样温度(可选)。优先级:CLI/调用方显式传入 > 本字段 > 全局默认;reasoning 模型建议按厂商文档设置 |
| `display_name` | 展示名(可选,缺省用档案名)。`client.name` 用于对话上下文中的 assistant 名,不再恒显示 "DeepSeek" |
| `fallback` | 失败转移档案名列表(可选,如 `["kimi", "local"]`,一层不递归)。主档案瞬时故障(HTTP 429/5xx、连接失败/超时)时按序转移;认证/参数类 4xx **不转移**(配置问题响亮暴露);失败档案进入 60s 冷却期(429 响应带 Retry-After 数字秒时按其延长)。`chat_completion`(对话压缩/轻量子代理)无条件转移;`stream_chat`(主对话流式)仅**首事件前**转移(已输出内容/用户中断绝不转移) |
| `failover_timeout_budget` | 整条转移链的总时间预算秒数(可选,缺省不限)。耗尽即停止尝试剩余档案,防止长链故障时超时叠加(3 档案最坏 3×timeout);`FailoverClient.get_stats()` 暴露 requests/failovers/cooldown_skips/exhausted 计数,`on_failover(from, to, reason)` 回调供管理面/前端通知接入 |
**选择优先级**:`--model <档案名>`(命中 `models` 节)→ `api.default_model` 指向档案 → 回退旧 `api` 扁平节。`--model` 为**进程级覆盖**:WebSocket 服务模式(主对话、每个会话实例、定时任务实例)与 `--once` 一次性模式均生效。
`--model` 传**裸模型名**(未命中档案)时,沿用默认端点与 Key,仅覆盖模型名——即 "同端点换模型" 语义。
**前缀逃逸**(档案名与裸模型名歧义时):`--model model:NAME` 强制按裸模型名解析(档案恰好叫 NAME 时仍走裸名语义);`--model profile:NAME` 强制按档案名解析(不存在则明确报错,不再静默落回裸名语义)。
**兼容性**:`models` 留空 `{}` 时行为与旧版完全一致(走 `api` 节),已有配置无需任何改动。
其余配置节:
```json
{
"context": {
"max_history_messages": 500, "max_history_messages": 500,
"max_context_messages": 120, "max_context_messages": 120,
"max_tokens": 8192, "max_tokens": 8192,
@@ -558,11 +641,14 @@ AI 可以通过 `update_soul` 工具动态更新 `99_SOUL.md`,通过 `update_i
"llm_max_tokens": 800 "llm_max_tokens": 800
}, },
"proxy": { "proxy": {
"type": "", "socks5": {
"host": "", "enabled": false,
"port": 0, "host": "127.0.0.1",
"username": "", "port": 1080,
"password": "" "username": "",
"password": null,
"password_env": "SOCKS5_PASSWORD"
}
} }
} }
``` ```
@@ -570,6 +656,8 @@ AI 可以通过 `update_soul` 工具动态更新 `99_SOUL.md`,通过 `update_i
> 默认模型为 `deepseek-v4-flash`(通过 `api.deepseek_model` 修改)。设置 `api.reasoning_enabled: true` 可启用思考模式,此时会在请求中添加 `thinking: {type: "enabled"}` 参数,模型会先进行思考再回答,实时展示思考过程(灰色斜体区域),回答质量更高且**支持工具调用**(文件操作、命令执行、记忆搜索等工具均可在思考模式下正常使用)。 > 默认模型为 `deepseek-v4-flash`(通过 `api.deepseek_model` 修改)。设置 `api.reasoning_enabled: true` 可启用思考模式,此时会在请求中添加 `thinking: {type: "enabled"}` 参数,模型会先进行思考再回答,实时展示思考过程(灰色斜体区域),回答质量更高且**支持工具调用**(文件操作、命令执行、记忆搜索等工具均可在思考模式下正常使用)。
> >
> API Key 从环境变量 `DEEPSEEK_API_KEY` 读取(亦支持 `~/.cert/deepseek.key` 文件)。 > API Key 从环境变量 `DEEPSEEK_API_KEY` 读取(亦支持 `~/.cert/deepseek.key` 文件)。
>
> **代理密码不落盘**:`proxy.socks5.password` 仅在运行期内存中有效,保存配置时强制置空;跨重启持久化请将密码存入环境变量(如 `SOCKS5_PASSWORD`),配置 `password_env` 指向该变量名。加载时检测到旧版明文密码会自动迁移(本会话保留可用,文件立即重写脱敏)。
> >
> **多厂商切换**:大模型调用已通过 `aibox/llm_client.py` 抽象层解耦,业务层不再直接依赖具体厂商。配置 `api.provider`(默认 `deepseek`)即可切换厂商,当前内置 `DeepSeekClient`(兼容 OpenAI Chat Completions 协议,Kimi / Qwen / GLM / Doubao 等绝大多数厂商均可复用)。新增厂商只需在此文件新增一个 `LLMClient` 子类并在 `create_llm_client` 工厂中注册,无需改动业务代码,为后续子代理/多模型共发预留了扩展点。 > **多厂商切换**:大模型调用已通过 `aibox/llm_client.py` 抽象层解耦,业务层不再直接依赖具体厂商。配置 `api.provider`(默认 `deepseek`)即可切换厂商,当前内置 `DeepSeekClient`(兼容 OpenAI Chat Completions 协议,Kimi / Qwen / GLM / Doubao 等绝大多数厂商均可复用)。新增厂商只需在此文件新增一个 `LLMClient` 子类并在 `create_llm_client` 工厂中注册,无需改动业务代码,为后续子代理/多模型共发预留了扩展点。
@@ -591,6 +679,14 @@ python aibox.py [选项]
--memo-add "标题|内容|时间" 添加任务 --memo-add "标题|内容|时间" 添加任务
--memo-list 列出任务 --memo-list 列出任务
--memo-complete ID 完成任务 --memo-complete ID 完成任务
--once QUERY 一次性对话:执行 QUERY 后退出(不启动 WebSocket)
--quiet 一次性模式静默输出(stdout 仅回答正文)
--show-reasoning 一次性模式输出思考流(默认静默,走 stderr)
--no-save-memory 一次性模式禁用记忆工具(干净跳过,不进请求 schema)
--worker-only 一次性模式纯执行面(worker profile,无人格/记忆工具)
--model NAME 模型档案名(models 节)或裸模型名(同端点换模型);歧义时 model:NAME 强制裸名 / profile:NAME 强制档案;服务模式与 --once 均生效(进程级覆盖)
--temperature T 覆盖配置的对话温度
--once-timeout SEC 一次性模式全局超时(默认 300 秒)
``` ```
### 使用示例 ### 使用示例
@@ -612,6 +708,30 @@ python aibox.py --memo-add "提醒开会|下午3点会议室A|+1h"
python aibox.py --memo-list python aibox.py --memo-list
``` ```
### 💻 CLI 一次性对话(--once)
不启动 WebSocket,单次执行后退出。回答正文走 **stdout**,工具过程/思考流走 **stderr**,可直接管道组合:
```bash
# 基础问答
python aibox.py --once "帮我看下当前日期"
# 管道组合(--quiet 静默模式)
python aibox.py --once --quiet "列出当前目录 py 文件" | grep ".py" | wc -l
# 按档案名选模型 / 同端点换模型
python aibox.py --once --model kimi "总结这段代码"
python aibox.py --once --model deepseek-v4-lite "1+1?"
# 纯执行面(无人格/记忆,单次收敛,适合脚本调用)
python aibox.py --once --worker-only "执行 echo hello 并返回输出"
# 显式开启思考流 / 禁用记忆写入
python aibox.py --once --show-reasoning --no-save-memory "分析这个问题"
```
**退出码**:`0` 成功 / `1` 工具执行失败 / `2` API 调用失败 / `3` 用户中断(Ctrl+C)/ `4` 全局超时。
## 🔄 架构流程 ## 🔄 架构流程
``` ```
@@ -791,6 +911,75 @@ elif command == "/mycommand":
## 📄 更新日志 ## 📄 更新日志
### v3.5
- ✅ **安全审计与修复(第三轮 Batch G + 第四轮 Batch H + 遗留收尾 Batch I)**(完整记录见 [docs/reports/aibox_audit_20260817.md](../docs/reports/aibox_audit_20260817.md)):
- **Batch G(llm 加固 + 线程生命周期 + 取消传播 + DB 事务)**:
- llm_client:非瞬时 HTTP 错误体截断 `[:200]`(密钥/错误页不进异常栈)、Retry-After 上限 cap 3600s、fallback 档案缺 model 跳过并告警、fallback 去重、删除 `LLMClient._cancelled` 死状态、FailoverClient 并发契约文档化
- 子代理取消传播:`run_full_subagent` 共享父 `_cancel_event` + 0.2s 观察者线程转发深度中断(强关在途 HTTP、kill 子进程);`SubAgentTool` 构造注入父事件
- drain 线程重构:批次级单线程 + stop 事件 + try/finally 统一回收(消除每次工具调用泄漏一个常驻线程);`/new` 的 `reset_conversation` 移入线程池(不再同步获取 `_msg_lock` 冻结事件循环);broadcast 收窄 except + unregister 改 discard;`_processing_tasks` 改按连接的任务集合登记(关闭时取消全部在途任务)
- DB:FTS5 关键词双引号翻倍转义(`"` → `""`)、两处 `LIMIT` f-string 参数化、`mark_as_triggered` 读写收进单连接 `BEGIN IMMEDIATE` 事务(消灭并发触发竞态);历史索引裸 except 收窄 + 归档文件名同秒加序号
- **Batch H(第四轮审计,3 路并行审读 + Linux PoC 6/6 实证)**:
- **H1(高危)**:修复 N5 回归——子代理共享父取消事件后首轮 think 无条件 `clear()` 会吞掉父级已置位的取消信号(用户中断 / --once 超时被绕过),共享事件时跳过 clear + 父已取消直接早退
- `/reload_prompts` 移入线程池(N3 同类遗漏);技能扫描(串行 `--help` 子进程)启动前预热,不再在首条消息/首个任务时冻结事件循环
- 调度器任务级超时(`scheduler.task_timeout`,默认 600s)——AI think 卡死不再停摆整个调度循环
- `get_recent_memories(limit=None)` 默认上限(不再全表加载);`memo delete` 改物理删除(此前只标记完成);`PromptManager.reload` 本地构建后原子替换(并发读者不再读到空/半截系统提示词)
- read_file 的 sed 参数强转 int(防引号注入);`set_proxy` 落盘失败如实报告;SSE 非 UTF-8 行容错;persona 路径 realpath 校验(软链越界拒绝);1MB 截断无半个字符;compressor 除零防护;InstallSkillTool 指引命令路径引用;ask_user 缺参明确报错
- **POSIX 进程组终止**:executor 的 `Popen` 加 `start_new_session` + 组信号 kill(组 ID 在 Popen 后立即捕获)——`(sleep 300 &)` 形态的孙进程持管道不再悬挂 reader 线程/泄漏 fd(实证修复前孙进程残留)
- web 工具 scheme 白名单(http/https,`file://` 等拒绝);registry 日志打码改包含匹配(apiKey/passwd 变体);FailoverClient 日志 Bearer 令牌正则脱敏(网关回显 Authorization 头不再进日志)
- **Batch I(遗留收尾)**:
- **N9**:prompt 写入内容长度上限(`MAX_CONTENT_LENGTH=10000`,identity/soul/status 超长拒绝)——约束持久化注入面
- **F5**:情绪子代理取消传播——`run_light_subagent` 改走 stream_chat + 观察者线程,用户中断可即时终止(此前阻塞至请求超时)
- 提醒时间格式异常任务按"到期"返回(此前被 SQL 层字符串比较静默丢弃,任务永不执行);`mark_as_triggered` 加 `busy_timeout=10000`(多进程同库写竞争等待而非立即报 locked)
- 对话文件原子写(tmp + os.replace,崩溃不留半截 JSON);`load_conversation` realpath 越界防护;WS 命令专用线程池(`/new`、`/reload_prompts` 不再占用默认池);--once 硬超时兜底(软超时后宽限一个周期强杀进程)
- PromptManager 单例加锁;`limit=0` 语义修正(None 才不加 LIMIT)
- ✅ **测试**:424 项全过(Linux 环境;Batch G 20 + Batch H 26 + Batch I 16 项新增;`test_execute_timeout`/`test_flat_api_still_reads_key_file` 为 Windows 向用例,Linux 预存失败与本轮无关)
### v3.4
- ✅ **多模型档案加固(H1/H2/H3/M1/M2/M3 六项修复)**:
- **H1(凭据隔离)**:档案端点环境变量取不到时直接报错,**绝不回退读 `~/.cert/deepseek.key`**(该文件为 DeepSeek 专属凭据,跨厂商复用属于泄漏);key 文件回退仅对无档案的旧 `api` 扁平节生效
- **H2(schema 校验前移)**:档案缺 `api_url`/`model` 或类型错误时,解析期即抛出指明档案名与缺失字段的 `ValueError`——不再把档案名当模型名发出、不再延迟到 `requests InvalidURL` 或 API 400
- **H3(--model 服务模式生效)**:`--model` 升级为进程级覆盖,WebSocket 服务模式(主对话/每个会话实例/定时任务实例)与 `--once` 均生效,此前服务模式静默忽略
- **M2(防模型名幻觉)**:`create_subagent` 工具描述与 JSON Schema 动态携带 `models` 节可用档案名列表(默认档案标注),LLM 无从编造名称;`models` 为空时描述保持原样(零破坏)
- **M1(换模型同步派生引用)**:新增 `DeepSeekChat.set_client()` 统一替换 LLM 客户端——同步刷新 `compressor.llm_client` 与 `api_key`/`name` 快照;`create_subagent` 子代理换模型经此入口,压缩链不再残留默认模型
- **M3(reasoning 契约显式化)**:档案级 `reasoning_enabled` 从"构造后挂实例属性"改为 `LLMClient` 构造参数(三态:`True`/`False` 档案级显式、`None` 未配置回退全局),外部误设属性无从静默覆盖全局开关
- ✅ **L 批体验与兼容修复(L1/L2/L3/L5 四项)**:
- **L1(展示名按档案)**:`client.name` = 档案 `display_name` > 档案名,用 Kimi/Qwen 时对话上下文不再误标 "DeepSeek";旧扁平节保持 "DeepSeek"(零破坏)
- **L2(前缀逃逸)**:`--model model:NAME` 强制裸模型名语义、`--model profile:NAME` 强制档案语义(不存在时明确报错并列出可用档案),档案名与模型名歧义可逃逸
- **L3(档案级温度)**:档案 `temperature` 字段作为默认采样温度,优先级:CLI/调用方显式传入 > 档案字段 > 全局默认(0.7/0.3),主对话/轻量子代理/compressor 全链路生效
- **L5(思考流字段别名)**:`_parse_delta` 识别 `reasoning_content`/`reasoning`/`thinking`/`thinking_content` 四种常见思考字段(首个命中生效),其他厂商思考流不再静默丢失
- ✅ **L4a(chat_completion 跨档案失败转移)**:档案新字段 `fallback`(档案名列表,一层不递归);`create_llm_client` 构造 `FailoverClient` 包装链(主档案 + fallback)。瞬时故障(HTTP 429/5xx 抛 `TransientLLMError`、连接失败/超时)按链转移,认证/参数类 4xx 不转移(配置问题响亮暴露);失败档案 60s 冷却;fallback 档案缺 Key 等环境问题跳过 + warning 不阻断主档案;配置结构错误(类型/不存在/自引用)启动期硬性拦截。当前覆盖对话压缩与轻量子代理链路,流式(`stream_chat`)转移属 L4b
- ✅ **L4b(stream_chat 首事件前转移 + 中断联动)**:流式仅在**未产出任何事件**(reasoning/content/tool_call)时转移——重放对用户不可见;已产出事件后失败直接上抛(防前端重复输出/工具增量错乱)。`LLMClient.cancel()` 置 `_cancelled` 标志,`FailoverClient.cancel()` 广播整条链并置包装器标志——用户中断引发的连接异常与真实故障不可区分,凭标志跳过转移(否则用户越中断、系统越换厂商重试);`chat_completion` 同受标志保护。链耗尽抛最后异常(既有回滚/报错处理接住),全链冷却抛 RuntimeError
- ✅ **L4c(转移可观测性 + 预算控制 + Retry-After)**:`FailoverClient.get_stats()` 暴露 requests/failovers/cooldown_skips/exhausted 聚合计数;`on_failover(from, to, reason)` 回调(to=None 表示链耗尽,回调异常不影响主流程)供管理面/前端通知接入;档案字段 `failover_timeout_budget` 限制整链尝试总秒数(缺省不限,<=0 视为未配置),耗尽停止尝试剩余档案防超时叠加;429 响应 Retry-After(数字秒)经 `TransientLLMError.retry_after` 延长该档案冷却期(非数字格式忽略)
- ✅ **测试**:355 项全过(H1 隔离 4 + H2 校验 6 + H3 传导 6 + M2 提示 5 + M1 替换 2 + M3 契约 4 + L 批 16 + L4a 转移 15 + L4b 流式 12 + L4c 可观测 13 项新增)
### v3.3
- ✅ **安全审计与修复**(两轮审计报告见 [docs/reports/aibox_audit_20260817.md](../docs/reports/aibox_audit_20260817.md)):
- **Batch E(命令执行保护链修复)**:`CommandLineTool.execute_streaming` 纯委托注入的 executor(删除绕过 1MB/超时/中断保护的 150 行重复 Popen 实现);`SubprocessCommandExecutor` 读取循环线程化(后台 reader 线程 + 0.1s 超时轮询,静默命令超时不再失效);两处 `Popen` 改 `stdin=DEVNULL`(等待输入的命令不再永久挂起工具线程);write_file 指引 heredoc 定界符唯一化;registry 流式转发循环加 3600s 总超时兜底。PoC 回归 5/5 + 全量单测通过
- **Batch F(凭据保护 + 线程收尾)**:
- **F1**:SOCKS5 代理密码不再明文落盘——`_save_config` 落盘前强制脱敏(纵深防御),新增 `password_env` 环境变量注入(对齐 `api_key_env` 惯例),旧版明文配置加载时自动迁移清除;`registry` debug 日志对 `password`/`api_key`/`token`/`secret`/`authorization` 参数打码
- **F2**:registry 流式转发超时路径统一清理 `tool.streaming_output`/`tool.output_queue`(消除僵尸 daemon 线程经实例属性把输出混入后续会话队列的串流路径),`stream_tool_timeout` 提为实例属性(默认 3600s,测试可调);`SubprocessCommandExecutor` reader 线程收尾——正常 EOF 后显式 close 释放管道 fd,仍阻塞时有限 join 后放弃(daemon 兜底);实证修正:CPython 流锁下阻塞中的 readline 无法被 `close()` 中断(首版实现致超时被拖满命令时长,已按 PoC 回归修正)
- ✅ **测试**:272 项全过(Batch E PoC 回归 + F1 配置脱敏 3 项 + F2 超时清理/线程收尾 5 项新增)
### v3.2
- ✅ **CLI 一次性对话模式(`--once`)**(分三批落地,开发文档见 [docs/reports/aibox_cli_once_mode_dev_doc.md](../docs/reports/aibox_cli_once_mode_dev_doc.md)):
- **Batch A**:`--once QUERY` / `--quiet` / `--once-timeout`;不启动 WebSocket,执行后退出;回答走 stdout、过程走 stderr,可管道组合;SIGINT 与超时复用三层取消机制
- **Batch B**:`--no-save-memory`(`enable_memory` 开关干净禁用 MemoryTool)、`--show-reasoning`(思考流默认静默);压缩提示改走 output_callback 不再污染 stdout;退出码 1(工具执行失败)
- **Batch C**:SQLite WAL(多 CLI 进程并发写锁缓解)、`--worker-only`(纯执行面 + 精简 system prompt 防人格污染)、`--model` / `--temperature` 覆盖、子代理工具失败标记透传(`_SUBAGENT_TOOL_FAILED_MARKER` → orchestrator stats)
- ✅ **多模型档案(`models` 配置节)**:配置多个 OpenAI Chat Completions 兼容端点(DeepSeek/Kimi/Qwen/本地 Ollama 等)按档案名切换;`--model` 支持档案名与裸模型名(同端点换模型)双语义;档案级 `api_key_env` 三级语义(回退 / 显式 / 空串免密);档案级 `reasoning_enabled` 优先于全局;`models: {}` 时零破坏回退旧 `api` 节;`llm_client.py` 增加 `openai_compatible` / `openai` provider 别名
- ✅ **测试**:264 项全过(once 模式 15 项 + 多模型档案 17 项新增)
### v3.1
- ✅ **修复 FTS5 中文搜索失效**([subsystems/db.py](subsystems/db.py)):
- FTS5 虚拟表改用 `tokenize='trigram'` 分词器(需 SQLite >= 3.34,环境不支持时自动退回 unicode61)
- 旧 unicode61 表在启动时自动迁移:DROP 重建后从 memories 表回灌全量数据
- `search_memories` 增加 LIKE 模糊检索兜底(trigram 对 <3 字符中文短词无法命中;关键词中 `%`/`_` 已转义为字面字符;MATCH 语法错误不再抛异常)
- ✅ **修复对话压缩 Token 阈值不生效**:
- `should_compress` 补齐 Token 数判断:消息条数或 Token 数任一达到阈值即触发压缩(此前 `compress_token_threshold` 为死配置)
- `compress_conversation` 配套:Token 触发(消息数未超阈值)时按比例压缩早期消息,不再因目标消息数计算而跳过
- ✅ **ConversationCompressor 抽离至 [subsystems/conversation_compressor.py](subsystems/conversation_compressor.py)**(Wave 3):
- 构造注入 `llm_client`(客户端抽象层)替代 AI 引擎实例,打破与 `DeepSeekChat` 的循环引用
- aibox.py 3517 → 3018 行;配套 17 项单元测试(估算/阈值判断/压缩流程/LLM 失败回退/孤立 tool 消息清理)
### v3.0 ### v3.0
- ✅ **模块化重构(aibox.py 8450 行 → 3706 行,缩减 56%)**: - ✅ **模块化重构(aibox.py 8450 行 → 3706 行,缩减 56%)**:
- **A 批基础工具抽离**([tools/basic_tools.py](tools/basic_tools.py)):`FileToolBase` / `ReadFileTool` / `WriteFileTool` / `ListFilesTool` / `SimpleWebCrawlerTool` / `GetCurrentTimeTool` / `CalculatorTool` 共 7 类,无外部子系统依赖;配套 43 项单元测试 - **A 批基础工具抽离**([tools/basic_tools.py](tools/basic_tools.py)):`FileToolBase` / `ReadFileTool` / `WriteFileTool` / `ListFilesTool` / `SimpleWebCrawlerTool` / `GetCurrentTimeTool` / `CalculatorTool` 共 7 类,无外部子系统依赖;配套 43 项单元测试
@@ -799,7 +988,7 @@ elif command == "/mycommand":
- **测试套件**:[tests/](tests/) 目录 171 项全过,覆盖 A/B 批工具与 Wave 1 子系统 - **测试套件**:[tests/](tests/) 目录 171 项全过,覆盖 A/B 批工具与 Wave 1 子系统
- **修复**:`subsystems/db.py` 的 `get_recent_memories(limit=None)` SQL datatype mismatch bug(`LIMIT ?` 传 None 时移除 LIMIT 子句) - **修复**:`subsystems/db.py` 的 `get_recent_memories(limit=None)` SQL datatype mismatch bug(`LIMIT ?` 传 None 时移除 LIMIT 子句)
- **架构约束**:建立 `common ← subsystems ← tools ← aibox` 单向依赖,禁止反向引用;`tools/__init__.py` 与 `subsystems/__init__.py` 采用"重导出"策略,调用点无需关心子模块位置 - **架构约束**:建立 `common ← subsystems ← tools ← aibox` 单向依赖,禁止反向引用;`tools/__init__.py` 与 `subsystems/__init__.py` 采用"重导出"策略,调用点无需关心子模块位置
- **未抽离**:Wave 2(`TaskScheduler` / `ConversationHistory`)、Wave 3(`ConversationCompressor`)、C 批工具(`ExpressEmotionTool` / `SubAgentTool`)、核心类(`DeepSeekChat` / `WebSocketHandler` / `EnhancedAIManager`)仍保留在 `aibox.py` 内 - **未抽离**:`TaskScheduler`、C 批工具(`ExpressEmotionTool` / `SubAgentTool`)、核心类(`DeepSeekChat` / `WebSocketHandler` / `EnhancedAIManager`)仍保留在 `aibox.py` 内(`ConversationHistory` 于 Wave 2、`ConversationCompressor` 于 v3.1 抽离)
### v2.9 ### v2.9
- ✅ **编排者-执行者工具分层(工具集 profile)**: - ✅ **编排者-执行者工具分层(工具集 profile)**:
+612 -721
View File
File diff suppressed because it is too large. Load diff
+83 -18
View File
@@ -8,6 +8,7 @@ common.config - aibox 配置管理器
import os import os
import json import json
import copy
from typing import Dict, Optional from typing import Dict, Optional
from .i18n import i18n from .i18n import i18n
@@ -64,14 +65,36 @@ class ConfigManager:
# 大模型调用接口 # 大模型调用接口
"api": { "api": {
"provider": "openai_compatible", # 提供商:deepseek / openai_compatible(OpenAI Chat Completions 协议兼容)
"deepseek_api_url": "https://api.deepseek.com/chat/completions", # deepseek API URL "deepseek_api_url": "https://api.deepseek.com/chat/completions", # deepseek API URL
"deepseek_model": "deepseek-v4-flash", # 模型名字 "deepseek_model": "deepseek-v4-flash", # 模型名字
"reasoning_enabled": False, # 开启思维链 "reasoning_enabled": False, # 开启思维链
"api_key_env": "DEEPSEEK_API_KEY", # API-KEY所在的环境变量 "api_key_env": "DEEPSEEK_API_KEY", # API-KEY所在的环境变量
"timeout": 120, # 调用大模型请求超时时间 "timeout": 120, # 调用大模型请求超时时间
"temperature": 0.7 # 大模型对话温度(类似思维发散程度) "temperature": 0.7, # 大模型对话温度(类似思维发散程度)
"default_model": "deepseek" # 默认模型档案名(对应 models 节的 key)
}, },
# 多模型档案:可配置多个 OpenAI Chat Completions 兼容端点,按名称选择
# 示例(在 ~/.aibox/config.json 中添加):
# "models": {
# "kimi": {
# "api_url": "https://api.moonshot.cn/v1/chat/completions",
# "model": "kimi-k2",
# "api_key_env": "MOONSHOT_API_KEY", # 未配置回退 api.api_key_env;空串 "" 为免密端点
# "reasoning_enabled": false,
# "timeout": 120
# },
# "local": {
# "api_url": "http://localhost:11434/v1/chat/completions",
# "model": "qwen3:32b",
# "api_key_env": ""
# }
# }
# 选择优先级:--model <档案名> > api.default_model > 回退旧 api 扁平节。
# 默认留空:用户未配置档案时走旧 api 节(零破坏)。
"models": {},
# 大模型上下文 # 大模型上下文
"context": { "context": {
"max_history_messages": 500, # 最大历史消息数量 "max_history_messages": 500, # 最大历史消息数量
@@ -165,6 +188,7 @@ class ConfigManager:
print(i18n.get('config_not_found', config_path)) print(i18n.get('config_not_found', config_path))
self._config = self._get_default_config() self._config = self._get_default_config()
self._save_default_config(config_path) self._save_default_config(config_path)
self._migrate_legacy_proxy_password()
def _merge_configs(self, default: Dict, user: Dict) -> Dict: def _merge_configs(self, default: Dict, user: Dict) -> Dict:
result = default.copy() result = default.copy()
@@ -198,6 +222,28 @@ class ConfigManager:
self._load_config() self._load_config()
def _resolve_proxy_password(self, proxy_conf: Dict) -> Optional[str]:
"""解析代理密码:内存明文优先,否则按 password_env 查环境变量。
对齐 api_key_env 惯例:配置文件只持久化环境变量名,明文密码不落盘。
"""
password = proxy_conf.get("password")
if password:
return password
env_name = proxy_conf.get("password_env")
if env_name:
return os.environ.get(env_name) or None
return None
def _migrate_legacy_proxy_password(self):
"""一次性迁移:旧版把代理密码明文落盘。加载后明文保留在内存供本会话
使用,同时立即重写脱敏配置文件,清除盘上存量明文。"""
socks = (self._config or {}).get("proxy", {}).get("socks5")
if socks and socks.get("password"):
print("⚠️ 检测到配置文件中的代理明文密码:已保留供本次会话使用,不再落盘。"
"跨重启请将密码存入环境变量,并把 proxy.socks5.password_env 指向该变量名")
self._save_config()
def get_proxy(self) -> Optional[Dict[str, str]]: def get_proxy(self) -> Optional[Dict[str, str]]:
"""获取当前代理配置(返回 requests 可用的 proxies 字典)""" """获取当前代理配置(返回 requests 可用的 proxies 字典)"""
proxy_conf = self.get("proxy.socks5") proxy_conf = self.get("proxy.socks5")
@@ -206,15 +252,20 @@ class ConfigManager:
host = proxy_conf.get("host", "127.0.0.1") host = proxy_conf.get("host", "127.0.0.1")
port = proxy_conf.get("port", 1080) port = proxy_conf.get("port", 1080)
username = proxy_conf.get("username") username = proxy_conf.get("username")
password = proxy_conf.get("password") password = self._resolve_proxy_password(proxy_conf)
if username and password: if username and password:
proxy_url = f"socks5://{username}:{password}@{host}:{port}" proxy_url = f"socks5://{username}:{password}@{host}:{port}"
else: else:
proxy_url = f"socks5://{host}:{port}" proxy_url = f"socks5://{host}:{port}"
return {"http": proxy_url, "https": proxy_url} return {"http": proxy_url, "https": proxy_url}
def set_proxy(self, host: str, port: int, username: str = None, password: str = None, enabled: bool = True, save: bool = True): def set_proxy(self, host: str, port: int, username: str = None, password: str = None,
"""动态设置代理配置(内存中更新,并可选择写入配置文件)""" password_env: str = None, enabled: bool = True, save: bool = True):
"""动态设置代理配置(内存中更新,并可选择写入配置文件)。
password 明文仅存内存(_save_config 落盘时强制脱敏);
跨重启持久化请用 password_env 指向存有密码的环境变量名。
"""
# 确保 proxy 节点存在 # 确保 proxy 节点存在
if "proxy" not in self._config: if "proxy" not in self._config:
self._config["proxy"] = {} self._config["proxy"] = {}
@@ -223,31 +274,45 @@ class ConfigManager:
"host": host, "host": host,
"port": port, "port": port,
"username": username, "username": username,
"password": password "password": password,
"password_env": password_env
} }
if save: if save:
self._save_config() return self._save_config()
return True return True
def _save_config(self): def _save_config(self) -> bool:
"""将当前配置保存到配置文件(覆盖)""" """将当前配置保存到配置文件(覆盖)。返回是否成功落盘。
落盘前对代理密码做纵深防御脱敏:无论调用路径如何,明文密码一律不写入文件。
L2(审计 2026-08-18):此前异常仅 print,set_proxy 无条件报"已保存",
磁盘只读等场景下用户以为已持久化,重启后配置丢失。
"""
config_path = self._config_path config_path = self._config_path
if config_path: if not config_path:
try: return False
os.makedirs(os.path.dirname(config_path), exist_ok=True) try:
with open(config_path, 'w', encoding='utf-8') as f: safe_config = copy.deepcopy(self._config)
json.dump(self._config, f, ensure_ascii=False, indent=2) socks = safe_config.get("proxy", {}).get("socks5")
print(f"✅ 配置已保存到 {config_path}") if socks and socks.get("password"):
except Exception as e: socks["password"] = None
print(f"⚠️ 保存配置失败: {e}") os.makedirs(os.path.dirname(config_path), exist_ok=True)
with open(config_path, 'w', encoding='utf-8') as f:
json.dump(safe_config, f, ensure_ascii=False, indent=2)
print(f"✅ 配置已保存到 {config_path}")
return True
except Exception as e:
print(f"⚠️ 保存配置失败: {e}")
return False
def get_proxy_info(self) -> Dict: def get_proxy_info(self) -> Dict:
"""获取代理配置的详细信息(用于显示)""" """获取代理配置的详细信息(用于显示,不回传任何密码值)"""
proxy_conf = self.get("proxy.socks5", {}) proxy_conf = self.get("proxy.socks5", {})
return { return {
"enabled": proxy_conf.get("enabled", False), "enabled": proxy_conf.get("enabled", False),
"host": proxy_conf.get("host", ""), "host": proxy_conf.get("host", ""),
"port": proxy_conf.get("port", 0), "port": proxy_conf.get("port", 0),
"username": proxy_conf.get("username"), "username": proxy_conf.get("username"),
"password": proxy_conf.get("password") is not None # 只显示是否存在 "password": self._resolve_proxy_password(proxy_conf) is not None, # 只显示是否存在
"password_env": proxy_conf.get("password_env") or ""
} }
+490 -35
View File
@@ -22,27 +22,48 @@ from typing import Dict, Iterator, List, Optional
import json import json
import os import os
import re
import time
import requests import requests
# L12(审计 2026-08-18):瞬时错误体可能回显网关/WAF 错误页中的
# Authorization 头,抹掉 Bearer 令牌后再进日志,防密钥落盘
_AUTH_HEADER_RE = re.compile(r"Bearer\s+[A-Za-z0-9._~+/=-]+")
def _sanitize_log_msg(msg: str) -> str:
"""日志脱敏:替换错误消息中的 Bearer 令牌。"""
if not msg:
return msg
return _AUTH_HEADER_RE.sub("Bearer ***", msg)
class LLMClient(ABC): class LLMClient(ABC):
"""大模型客户端抽象基类。子类需实现流式/非流式两种调用,并归一化为统一事件格式。""" """大模型客户端抽象基类。子类需实现流式/非流式两种调用,并归一化为统一事件格式。"""
def __init__(self, api_key: str, model: str, base_url: str, def __init__(self, api_key: str, model: str, base_url: str,
timeout: int, config=None, logger=None): timeout: int, config=None, logger=None,
reasoning_enabled: bool = None,
display_name: str = None, default_temperature: float = None):
self.api_key = api_key self.api_key = api_key
self.model = model self.model = model
self.base_url = base_url self.base_url = base_url
self.timeout = timeout self.timeout = timeout
self.config = config # 可选,用于读取代理等动态配置 self.config = config # 可选,用于读取代理等动态配置
self.logger = logger # 可选,用于日志输出 self.logger = logger # 可选,用于日志输出
# M3:档案级思考模式开关为构造契约;None = 未配置(消费方回退全局 api.reasoning_enabled)
self.reasoning_enabled = reasoning_enabled
# L1:展示名(默认取档案名),业务层上下文(如 history 的 assistant 名)不再误标 "DeepSeek"
self._display_name = display_name
# L3:档案级默认采样温度;None = 未配置(方法参数未显式传入时回退硬编码默认值)
self.default_temperature = default_temperature
# 当前活动的 HTTP 响应引用,供 cancel() 强制关闭底层连接以中断阻塞读取 # 当前活动的 HTTP 响应引用,供 cancel() 强制关闭底层连接以中断阻塞读取
self._current_response = None self._current_response = None
@property @property
def name(self) -> str: def name(self) -> str:
"""厂商/模型展示名,业务层可用于上下文(如 history 里的 assistant 名)。""" """厂商/模型展示名,业务层可用于上下文(如 history 里的 assistant 名)。"""
return "LLM" return self._display_name or "LLM"
# ---------- 工具方法 ---------- # ---------- 工具方法 ----------
@@ -55,12 +76,16 @@ class LLMClient(ABC):
def _log(self, level: str, msg: str): def _log(self, level: str, msg: str):
if self.logger is not None and hasattr(self.logger, level): if self.logger is not None and hasattr(self.logger, level):
try: try:
getattr(self.logger, level)(msg) getattr(self.logger, level)(_sanitize_log_msg(msg))
except Exception: except Exception:
pass pass
def cancel(self): def cancel(self):
"""中断当前流式请求。线程安全,可从任意线程调用。""" """中断当前流式请求。线程安全,可从任意线程调用。
注:用户中断标志由 FailoverClient 维护(其 cancel() 广播整条链并置
自身 _cancelled,据此跳过转移)——底层 client 只负责强关连接。
"""
resp = self._current_response resp = self._current_response
self._current_response = None self._current_response = None
if resp is not None: if resp is not None:
@@ -78,16 +103,18 @@ class LLMClient(ABC):
# ---------- 抽象接口 ---------- # ---------- 抽象接口 ----------
@abstractmethod @abstractmethod
def chat_completion(self, messages: List[Dict], temperature: float = 0.3, def chat_completion(self, messages: List[Dict], temperature: float = None,
max_tokens: int = 1024, **kwargs) -> str: max_tokens: int = 1024, **kwargs) -> str:
"""非流式对话,返回完整文本。用于对话压缩等离线摘要场景。""" """非流式对话,返回完整文本。用于对话压缩等离线摘要场景。
temperature=None 时按 default_temperature(档案级)→ 0.3 回退。"""
@abstractmethod @abstractmethod
def stream_chat(self, messages: List[Dict], temperature: float = 0.7, def stream_chat(self, messages: List[Dict], temperature: float = None,
max_tokens: int = 8192, tools: Optional[List[Dict]] = None, max_tokens: int = 8192, tools: Optional[List[Dict]] = None,
tool_choice: Optional[str] = None, tool_choice: Optional[str] = None,
reasoning_enabled: bool = False, **extra) -> Iterator[Dict]: reasoning_enabled: bool = False, **extra) -> Iterator[Dict]:
"""流式对话,逐条 yield 统一事件格式的 dict。业务层据此驱动输出/工具调用。""" """流式对话,逐条 yield 统一事件格式的 dict。业务层据此驱动输出/工具调用。
temperature=None 时按 default_temperature(档案级)→ 0.7 回退。"""
class DeepSeekClient(LLMClient): class DeepSeekClient(LLMClient):
@@ -99,12 +126,18 @@ class DeepSeekClient(LLMClient):
""" """
def __init__(self, api_key: str, model: str, base_url: str, def __init__(self, api_key: str, model: str, base_url: str,
timeout: int = 30, config=None, logger=None): timeout: int = 30, config=None, logger=None,
super().__init__(api_key, model, base_url, timeout, config, logger) reasoning_enabled: bool = None,
display_name: str = None, default_temperature: float = None):
super().__init__(api_key, model, base_url, timeout, config, logger,
reasoning_enabled=reasoning_enabled,
display_name=display_name,
default_temperature=default_temperature)
@property @property
def name(self) -> str: def name(self) -> str:
return "DeepSeek" # L1:档案命中时展示档案名(或档案 display_name),旧扁平节保持 "DeepSeek"
return self._display_name or "DeepSeek"
def _headers(self) -> Dict[str, str]: def _headers(self) -> Dict[str, str]:
return { return {
@@ -112,8 +145,11 @@ class DeepSeekClient(LLMClient):
"Content-Type": "application/json", "Content-Type": "application/json",
} }
def chat_completion(self, messages: List[Dict], temperature: float = 0.3, def chat_completion(self, messages: List[Dict], temperature: float = None,
max_tokens: int = 1024, **kwargs) -> str: max_tokens: int = 1024, **kwargs) -> str:
# L3:None → 档案级 default_temperature → 硬编码默认(与旧版行为一致)
if temperature is None:
temperature = self.default_temperature if self.default_temperature is not None else 0.3
payload = { payload = {
"model": self.model, "model": self.model,
"messages": messages, "messages": messages,
@@ -126,22 +162,35 @@ class DeepSeekClient(LLMClient):
self.base_url, self.base_url,
headers=self._headers(), headers=self._headers(),
json=payload, json=payload,
timeout=30, timeout=self.timeout,
proxies=self._resolve_proxies(), proxies=self._resolve_proxies(),
) )
if response.status_code == 200: if response.status_code == 200:
result = response.json() result = response.json()
return result["choices"][0]["message"]["content"].strip() return result["choices"][0]["message"]["content"].strip()
if response.status_code == 429 or response.status_code >= 500:
# L4a:瞬时故障显式抛出——FailoverClient 据此转移下一档案;
# 无包装器时由调用方 catch-all 接住,下游仍走失败分支(语义等价)
raise TransientLLMError(
f"LLM 请求瞬时失败: HTTP {response.status_code}: {response.text[:200]}",
retry_after=_retry_after_of(response))
self._log("error", f"LLM 压缩请求失败: HTTP {response.status_code}") self._log("error", f"LLM 压缩请求失败: HTTP {response.status_code}")
return None return None
except TransientLLMError:
raise # L4a:瞬时故障上抛供 FailoverClient 转移,不吞
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
raise # L4a:连接类瞬时故障同样上抛(原被吞成 None,无法转移)
except Exception as e: except Exception as e:
self._log("error", f"LLM 压缩请求异常: {e}") self._log("error", f"LLM 压缩请求异常: {e}")
return None return None
def stream_chat(self, messages: List[Dict], temperature: float = 0.7, def stream_chat(self, messages: List[Dict], temperature: float = None,
max_tokens: int = 8192, tools: Optional[List[Dict]] = None, max_tokens: int = 8192, tools: Optional[List[Dict]] = None,
tool_choice: Optional[str] = None, tool_choice: Optional[str] = None,
reasoning_enabled: bool = False, **extra) -> Iterator[Dict]: reasoning_enabled: bool = False, **extra) -> Iterator[Dict]:
# L3:None → 档案级 default_temperature → 硬编码默认(与旧版行为一致)
if temperature is None:
temperature = self.default_temperature if self.default_temperature is not None else 0.7
payload = { payload = {
"model": self.model, "model": self.model,
"messages": messages, "messages": messages,
@@ -170,14 +219,19 @@ class DeepSeekClient(LLMClient):
self._current_response = response self._current_response = response
if response.status_code != 200: if response.status_code != 200:
if response.status_code == 429 or response.status_code >= 500:
# L4b:瞬时故障显式分类(此时尚未产出任何事件,可安全转移重放)
raise TransientLLMError(
f"LLM 流式请求瞬时失败: HTTP {response.status_code}: {response.text[:200]}",
retry_after=_retry_after_of(response))
raise RuntimeError( raise RuntimeError(
f"HTTP {response.status_code}: {response.text}") f"HTTP {response.status_code}: {response.text[:200]}")
try: try:
for line in response.iter_lines(): for line in response.iter_lines():
if not line: if not line:
continue continue
line_text = line.decode("utf-8") line_text = line.decode("utf-8", errors="replace") # L3:非 UTF-8 行不中断流
if not line_text.startswith("data: "): if not line_text.startswith("data: "):
continue continue
data = line_text[6:] data = line_text[6:]
@@ -198,11 +252,19 @@ class DeepSeekClient(LLMClient):
finally: finally:
self._current_response = None self._current_response = None
# L5:思考内容的常见厂商字段别名(首个命中的非空字段生效)
REASONING_FIELDS = ("reasoning_content", "reasoning", "thinking", "thinking_content")
def _parse_delta(self, delta: Dict, reasoning_enabled: bool) -> Iterator[Dict]: def _parse_delta(self, delta: Dict, reasoning_enabled: bool) -> Iterator[Dict]:
"""将单条 delta 归一化为统一事件。子类覆写以适配不同厂商字段。""" """将单条 delta 归一化为统一事件。子类覆写以适配不同厂商字段。"""
# 思考内容 # 思考内容:DeepSeek reasoning_content / OpenRouter reasoning /
if reasoning_enabled and delta.get("reasoning_content"): # 部分 GLM thinking(_content) —— 此前仅识别 DeepSeek 字段,其他厂商思考流静默丢失
yield {"type": "reasoning", "text": delta["reasoning_content"]} if reasoning_enabled:
for field in self.REASONING_FIELDS:
value = delta.get(field)
if value:
yield {"type": "reasoning", "text": value}
break
# 正文 # 正文
if delta.get("content"): if delta.get("content"):
yield {"type": "content", "text": delta["content"]} yield {"type": "content", "text": delta["content"]}
@@ -219,17 +281,251 @@ class DeepSeekClient(LLMClient):
} }
def resolve_api_key(api_key: Optional[str], config=None) -> str: class TransientLLMError(RuntimeError):
"""解析 API Key:优先显式传入,其次环境变量,最后 ~/.cert/deepseek.key。""" """瞬时 LLM 故障(HTTP 429/5xx)——FailoverClient 据此转移下一档案(L4a)。
L4c:retry_after 携带服务端 Retry-After 响应头(数字秒)建议冷却时长,
FailoverClient 以其替代默认冷却期(429 限流时尊重服务端节奏)。
"""
def __init__(self, message: str, retry_after: Optional[float] = None):
super().__init__(message)
self.retry_after = retry_after
def _retry_after_of(response) -> Optional[float]:
"""从 Retry-After 响应头解析冷却秒数(仅支持数字秒格式,其余忽略返回 None)。
P2:上限 cap 3600s——异常/恶意服务端返回超大值(如 999999)时,
避免档案被单条响应头冷却数天。
"""
try:
v = response.headers.get("Retry-After")
if v is None:
return None
return min(max(1.0, float(v)), 3600.0)
except (TypeError, ValueError, AttributeError):
return None
class FailoverClient:
"""跨档案失败转移包装器(L4a)。
持有序 client 链(主档案 + fallback 档案),对外实现与 LLMClient 相同的
消费面(未显式定义的属性经 __getattr__ 委托主 client)。
L4a 范围——chat_completion(非流式幂等)按链转移:
- 转移:HTTP 429/5xx(TransientLLMError)、连接失败/超时(requests 连接类异常)
- 不转移:认证/参数类 4xx(底层返回 None,直接透传——配置问题应响亮暴露)
失败档案进入冷却期(cooldown_seconds,默认 60s),期间直接跳过,
避免每次请求都撞刚失败的端点。链耗尽返回 None(与 chat_completion 失败契约一致)。
L4b 范围——stream_chat 仅**首事件前**故障转移(连接失败/HTTP 429/5xx,
尚未产出任何 reasoning/content/tool_call 事件,重放对用户不可见);
已产出事件后失败直接上抛(重放会导致前端重复输出/工具增量错乱);
用户中断(cancel() 置位)引发的连接异常绝不转移。
L4c 范围——可观测性与预算控制:
- get_stats() 暴露 requests/failovers/cooldown_skips/exhausted 聚合计数
- on_failover(from_name, to_name, reason) 回调(to_name=None 表示链耗尽),
供管理面/前端通知接入,回调异常不影响主流程
- timeout_budget 限制整条链尝试的总秒数(缺省不限),耗尽即停止尝试剩余档案,
防止长链故障时超时叠加(3 档案最坏 3×timeout)
- 429 响应带 Retry-After(数字秒)时按其延长该档案冷却期
并发契约(P4):实例非线程安全(_cancelled/_call_started/_stats 无锁)。
当前调用面满足"单线程串行调用 + cancel() 可从其他线程"的模式(compressor
在 _msg_lock 内同步调用、子代理各持独立实例);如需多线程共享调用,
需由调用方加锁或为实例补锁。
"""
def __init__(self, clients: List, logger=None, cooldown_seconds: float = 60.0,
timeout_budget: Optional[float] = None, on_failover=None):
self._clients = list(clients)
self.logger = logger
self.cooldown_seconds = cooldown_seconds
self._fail_at = {} # client.name → (失败时刻, 冷却秒数)
self._cancelled = False
self._stats = {"requests": 0, "failovers": 0, "cooldown_skips": 0, "exhausted": 0}
if timeout_budget is not None and timeout_budget <= 0:
self._log("warning", f"timeout_budget 非法({timeout_budget} <= 0),按未配置处理")
timeout_budget = None
self.timeout_budget = timeout_budget
self.on_failover = on_failover
self._call_started = None
def __getattr__(self, item):
# 未显式定义的属性/方法委托主 client(name/model/api_key/cancel 等);
# 下划线属性直接报错,防 __init__ 前访问 self._clients 的无限递归
if item.startswith("_"):
raise AttributeError(item)
return getattr(self._clients[0], item)
def _log(self, level: str, msg: str):
if self.logger is not None and hasattr(self.logger, level):
try:
getattr(self.logger, level)(_sanitize_log_msg(msg))
except Exception:
pass
def _cooling(self, client) -> bool:
entry = self._fail_at.get(client.name)
if entry is None:
return False
t, cd = entry
return (time.monotonic() - t) < cd
def _mark_fail(self, client, cooldown: Optional[float] = None):
# L4c:429 Retry-After(经 TransientLLMError.retry_after)覆盖默认冷却时长
self._fail_at[client.name] = (time.monotonic(),
cooldown if cooldown and cooldown > 0
else self.cooldown_seconds)
def _budget_exhausted(self) -> bool:
if self.timeout_budget is None or self._call_started is None:
return False
return (time.monotonic() - self._call_started) >= self.timeout_budget
def get_stats(self) -> Dict:
"""L4c:转移统计快照(requests/failovers/cooldown_skips/exhausted)。"""
return dict(self._stats)
def _notify_failover(self, from_name: str, to_name, reason: str):
self._stats["failovers"] += 1
try:
if self.on_failover is not None:
self.on_failover(from_name, to_name, _sanitize_log_msg(reason))
except Exception as e:
self._log("warning", f"on_failover 回调异常(忽略): {e}")
def cancel(self):
"""L4b:中断链上全部 client(任一可能正在流式中)并置自身中断标志。
显式定义以覆盖 __getattr__ 委托——包装器必须同时通知整条链。
"""
self._cancelled = True
for client in self._clients:
try:
client.cancel()
except Exception:
pass
def chat_completion(self, messages: List[Dict], temperature: float = None,
max_tokens: int = 1024, **kwargs):
self._cancelled = False # 新一轮请求,清除上一轮中断标志
self._call_started = time.monotonic()
self._stats["requests"] += 1
last_exc = None
tried = 0
for idx, client in enumerate(self._clients):
if self._cooling(client):
self._stats["cooldown_skips"] += 1
self._log("info", f"模型档案 {client.name} 处于失败冷却期,跳过")
continue
if self._budget_exhausted():
self._log("warning",
f"失败转移时间预算({self.timeout_budget}s)已耗尽,停止尝试剩余档案")
break
tried += 1
try:
result = client.chat_completion(messages, temperature=temperature,
max_tokens=max_tokens, **kwargs)
if result is not None:
return result
# None:认证/参数类 4xx 或响应异常——不可转移,直接透传
return None
except (TransientLLMError, requests.exceptions.RequestException) as e:
if self._cancelled:
return None # 用户中断引发的异常:不转移
last_exc = e
self._mark_fail(client, cooldown=getattr(e, "retry_after", None))
to_name = (self._clients[idx + 1].name
if idx + 1 < len(self._clients) else None)
self._notify_failover(client.name, to_name, str(e))
self._log("warning", f"模型档案 {client.name} 瞬时故障({e}),转移下一档案")
self._stats["exhausted"] += 1 # 末尾统一计数(链耗尽/预算耗尽/全冷却)
self._log("error", f"全部 {len(self._clients)} 个模型档案均不可用(尝试 {tried} 个):{last_exc}")
return None
def stream_chat(self, messages: List[Dict], temperature: float = None,
max_tokens: int = 8192, tools: Optional[List[Dict]] = None,
tool_choice: Optional[str] = None,
reasoning_enabled: bool = False, **extra) -> Iterator[Dict]:
"""L4b:流式转移——仅首事件前故障可安全重放(对用户不可见)。"""
self._cancelled = False # 新一轮请求,清除上一轮中断标志
self._call_started = time.monotonic()
self._stats["requests"] += 1
last_exc = None
for idx, client in enumerate(self._clients):
if self._cooling(client):
self._stats["cooldown_skips"] += 1
self._log("info", f"模型档案 {client.name} 处于失败冷却期,跳过")
continue
if self._budget_exhausted():
self._log("warning",
f"失败转移时间预算({self.timeout_budget}s)已耗尽,停止尝试剩余档案")
break
produced_any = False
try:
for event in client.stream_chat(
messages, temperature=temperature, max_tokens=max_tokens,
tools=tools, tool_choice=tool_choice,
reasoning_enabled=reasoning_enabled, **extra):
produced_any = True
yield event
return # 自然结束(含 done 事件)视为成功
except (TransientLLMError, requests.exceptions.RequestException) as e:
if self._cancelled:
raise # 用户中断(cancel 强关连接):绝不转移
if produced_any:
raise # 已向消费方产出事件,重放会重复输出/工具增量错乱
last_exc = e
self._mark_fail(client, cooldown=getattr(e, "retry_after", None))
to_name = (self._clients[idx + 1].name
if idx + 1 < len(self._clients) else None)
self._notify_failover(client.name, to_name, str(e))
self._log("warning",
f"模型档案 {client.name} 流式瞬时故障(首事件前,{e}),转移下一档案")
self._stats["exhausted"] += 1
if last_exc is not None:
raise last_exc # 链耗尽:抛最后异常,由既有错误处理(回滚/报错)接住
raise RuntimeError(
f"全部 {len(self._clients)} 个模型档案不可用(冷却期或转移时间预算耗尽)")
def resolve_api_key(api_key: Optional[str], config=None,
env_name: Optional[str] = None,
allow_missing: bool = False,
allow_key_file: bool = True) -> str:
"""解析 API Key:优先显式传入,其次环境变量,最后 ~/.cert/deepseek.key。
env_name: 档案级环境变量名(来自 models.<name>.api_key_env);
None 时回退 config 的 api.api_key_env。
allow_missing: 本地免密端点(models.<name>.api_key_env 为空串)时,
允许无 Key,返回空串(Bearer 空头,Ollama/vLLM 忽略)。
allow_key_file: 是否允许回退读取 ~/.cert/deepseek.key。该文件是 DeepSeek
专属凭据,模型档案端点(其他厂商)必须传 False——环境变量取不到
时直接报错,绝不把 A 厂商 Key 发给 B 厂商(跨厂商凭据泄漏)。
"""
if api_key: if api_key:
return api_key return api_key
env_name = None if env_name is None:
if config is not None and hasattr(config, "get"): if config is not None and hasattr(config, "get"):
env_name = config.get("api.api_key_env") env_name = config.get("api.api_key_env")
env_name = env_name or "DEEPSEEK_API_KEY" env_name = env_name or "DEEPSEEK_API_KEY"
if not env_name: # 档案显式置空 → 免密端点
if allow_missing:
return ""
raise RuntimeError("模型档案 api_key_env 配置为空且未启用免密")
env_key = os.environ.get(env_name) env_key = os.environ.get(env_name)
if env_key: if env_key:
return env_key return env_key
if allow_missing:
return ""
if not allow_key_file:
raise RuntimeError(
f"模型档案端点缺少 API Key:请设置环境变量 {env_name},"
f"或将该档案的 api_key_env 配置为空串以启用本地免密端点")
try: try:
home = os.environ.get("HOME", os.environ["USERPROFILE"]) home = os.environ.get("HOME", os.environ["USERPROFILE"])
key_path = os.path.join(home, ".cert", "deepseek.key") key_path = os.path.join(home, ".cert", "deepseek.key")
@@ -240,26 +536,184 @@ def resolve_api_key(api_key: Optional[str], config=None) -> str:
f"请设置环境变量 {env_name} 或在配置文件中设置 API Key") f"请设置环境变量 {env_name} 或在配置文件中设置 API Key")
def _validate_profile(name: str, profile) -> None:
"""H2:档案类型与 api_url 校验(在读取档案字段之前调用)。"""
if not isinstance(profile, dict):
raise ValueError(
f"模型档案 '{name}' 配置无效:应为对象(字典),"
f"实际为 {type(profile).__name__},请检查 config.json 的 models.{name}")
if not str(profile.get("api_url") or "").strip():
raise ValueError(
f"模型档案 '{name}' 缺少 api_url 字段,"
f"请在 config.json 的 models.{name} 中补齐(OpenAI Chat Completions 端点 URL)")
fb = profile.get("fallback")
if fb is not None and (not isinstance(fb, list) or not all(isinstance(x, str) for x in fb)):
raise ValueError(
f"模型档案 '{name}' 的 fallback 字段无效:应为档案名字符串列表"
f"(如 [\"kimi\", \"local\"]),实际为 {type(fb).__name__},"
f"请检查 config.json 的 models.{name}")
def _require_model(name: str, effective_model) -> None:
"""H2:有效模型名校验——缺 model 会把档案名当模型名发给 API(或 "model": null → 400)。"""
if not str(effective_model or "").strip():
raise ValueError(
f"模型档案 '{name}' 缺少 model 字段(且未传入裸模型名),"
f"请在 config.json 的 models.{name} 中补齐,或调用时用 --model 指定模型名")
def _resolve_model_profile(config, model: Optional[str]):
"""解析模型档案。返回 (profile_dict, effective_model_name, profile_name)。
优先级:model 命中 models.<name> 档案 > api.default_model 档案 > None(回退旧 api 节)。
model 未命中档案时视为裸模型名(同端点换模型,用默认档案的 url/key,模型名用裸名)。
L2 前缀逃逸(档案名与裸模型名歧义时使用):
- "model:NAME" 强制按裸模型名解析(档案恰好叫 NAME 时仍走裸名语义)
- "profile:NAME" 强制按档案名解析(不存在则报错,不再静默落回裸名语义)
"""
if config is None or not hasattr(config, "get"):
return None, model, None
models = config.get("models") or {}
if not isinstance(models, dict):
return None, model, None
force_bare = False
if isinstance(model, str) and ":" in model:
prefix, _, rest = model.partition(":")
if prefix == "model":
if not rest.strip():
raise ValueError("model: 前缀后为空,请提供裸模型名,如 model:deepseek-chat")
model, force_bare = rest, True
elif prefix == "profile":
if not rest.strip():
raise ValueError("profile: 前缀后为空,请提供档案名,如 profile:kimi")
prof = models.get(rest)
if prof is None:
raise ValueError(
f"模型档案 '{rest}' 不存在,可用档案:{', '.join(models) or '(无)'}")
_validate_profile(rest, prof)
effective = prof.get("model")
_require_model(rest, effective)
return prof, effective, rest
explicit = models.get(model) if (model and not force_bare) else None
default_name = config.get("api.default_model")
default_profile = models.get(default_name) if default_name else None
if explicit is not None:
# model 是档案名 → 完整采用该档案(url/key/模型名);
# 档案缺 model 属配置错误,直接报错(不再把档案名当模型名发出)
_validate_profile(model, explicit)
effective = explicit.get("model")
_require_model(model, effective)
return explicit, effective, model
if default_profile is not None:
# model 是裸名或未指定 → 默认档案的 url/key,模型名被裸名覆盖
_validate_profile(default_name, default_profile)
effective = model or default_profile.get("model")
_require_model(default_name, effective)
return default_profile, effective, default_name
return None, model, None
def _construct_profile_client(config, profile: Dict, profile_name: str,
effective_model: str, logger=None,
api_key: Optional[str] = None) -> LLMClient:
"""L4a:按档案构造单个 DeepSeekClient(主路径与 fallback 链复用,不处理 fallback 字段)。"""
env_name = profile.get("api_key_env")
allow_missing = (env_name == "") # 档案显式空串 → 本地免密端点
resolved_key = resolve_api_key(api_key, config, env_name=env_name,
allow_missing=allow_missing, allow_key_file=False)
return DeepSeekClient(
api_key=resolved_key,
model=effective_model,
base_url=profile.get("api_url"),
timeout=profile.get("timeout") or config.get("api.timeout", 30),
config=config,
logger=logger,
reasoning_enabled=profile.get("reasoning_enabled",
config.get("api.reasoning_enabled", False)),
display_name=profile.get("display_name") or profile_name,
default_temperature=profile.get("temperature"),
)
def create_llm_client(config, api_key: Optional[str] = None, def create_llm_client(config, api_key: Optional[str] = None,
logger=None, model: Optional[str] = None) -> LLMClient: logger=None, model: Optional[str] = None) -> LLMClient:
"""根据配置创建对应厂商的 LLM 客户端。可通过 `api.provider` 切换厂商。 """根据配置创建对应厂商的 LLM 客户端。
模型选择优先级:
1. model 命中 models 节档案名 → 采用该档案(api_url/model/api_key_env/timeout)
2. api.default_model 指向的档案 → model 为裸模型名时覆盖档案内模型名
3. 均未配置 → 回退旧 api 扁平节(deepseek_api_url/deepseek_model)
L2 前缀逃逸:"model:NAME" 强制裸名 / "profile:NAME" 强制档案(--model 同样支持)。
provider: deepseek / openai_compatible / openai 均映射到 DeepSeekClient
(OpenAI Chat Completions 协议兼容实现,厂商差异由子类覆写 _parse_delta)。
Args: Args:
config: 配置对象(需提供 get 方法)。 config: 配置对象(需提供 get 方法)。
api_key: 显式传入的 API Key;为 None 时按 resolve_api_key 解析。 api_key: 显式传入的 API Key;为 None 时按 resolve_api_key 解析。
logger: 可选日志对象。 logger: 可选日志对象。
model: 可选模型名覆盖;为 None 时使用配置中的 `api.deepseek_model`。 model: 档案名或裸模型名(支持 model:/profile: 前缀逃逸)。
子代理等场景可通过此参数显式指定与主代理不同的模型。
""" """
provider = (config.get("api.provider", "deepseek") or "deepseek").lower() profile, model, profile_name = _resolve_model_profile(config, model)
base_url = config.get("api.deepseek_api_url")
model = model or config.get("api.deepseek_model", "deepseek-v4-flash")
timeout = config.get("api.timeout", 30)
resolved_key = resolve_api_key(api_key, config) if profile is None:
# 旧 api 扁平节(唯一允许回退 ~/.cert/deepseek.key 的路径,H1)
provider = (config.get("api.provider", "deepseek") or "deepseek").lower()
base_url = config.get("api.deepseek_api_url")
model = model or config.get("api.deepseek_model", "deepseek-v4-flash")
timeout = config.get("api.timeout", 30)
resolved_key = resolve_api_key(api_key, config, env_name=None,
allow_missing=False, allow_key_file=True)
else:
provider = "openai_compatible"
primary = _construct_profile_client(config, profile, profile_name,
model, logger=logger, api_key=api_key)
fallback_names = profile.get("fallback") or []
if not fallback_names:
return primary
# L4a:构造 fallback 链(一层,不递归——fallback 档案自身的 fallback 字段被忽略)
models = config.get("models") or {}
chain = [primary]
seen = {profile_name} # P6:重复项去重(冷却键同名冲突/重复撞同端点)
for fb_name in fallback_names:
if fb_name == profile_name:
raise ValueError(
f"模型档案 '{profile_name}' 的 fallback 不能包含自身,"
f"请检查 config.json 的 models.{profile_name}.fallback")
if fb_name in seen:
if logger is not None and hasattr(logger, "warning"):
logger.warning(f"fallback 列表重复项 '{fb_name}',已忽略")
continue
seen.add(fb_name)
fb_profile = models.get(fb_name)
if fb_profile is None:
raise ValueError(
f"模型档案 '{profile_name}' 的 fallback 指向不存在的档案 '{fb_name}',"
f"可用档案:{', '.join(models) or '(无)'}")
try:
# P3:fallback 档案与主档案同等校验(缺 api_url/model 时跳过并告警,
# 而非构造出 model=None 的 client 在请求期表现为神秘失败)
_validate_profile(fb_name, fb_profile)
fb_model = fb_profile.get("model")
_require_model(fb_name, fb_model)
chain.append(_construct_profile_client(
config, fb_profile, fb_name, fb_model,
logger=logger))
except (RuntimeError, ValueError) as e:
# 环境问题(如 fallback 档案缺 API Key)或配置缺陷(缺 model):
# 跳过该 fallback 并告警,不阻断主档案可用性
# (配置结构错误——fallback 指向不存在的档案——在上方已硬性拦截)
if logger is not None and hasattr(logger, "warning"):
logger.warning(f"fallback 档案 '{fb_name}' 构造失败,已忽略:{e}")
return FailoverClient(chain, logger=logger,
timeout_budget=profile.get("failover_timeout_budget"))
if provider in ("deepseek", "deepseek-chat"): if provider in ("deepseek", "deepseek-chat", "openai_compatible", "openai"):
return DeepSeekClient( client = DeepSeekClient(
api_key=resolved_key, api_key=resolved_key,
model=model, model=model,
base_url=base_url, base_url=base_url,
@@ -267,6 +721,7 @@ def create_llm_client(config, api_key: Optional[str] = None,
config=config, config=config,
logger=logger, logger=logger,
) )
return client
# 其他 OpenAI 兼容厂商:可直接复用 DeepSeekClient(继承其协议解析), # 其他 OpenAI 兼容厂商:可直接复用 DeepSeekClient(继承其协议解析),
# 或在此接入专有实现。新增厂商时在此扩展即可。 # 或在此接入专有实现。新增厂商时在此扩展即可。
+415
View File
@@ -0,0 +1,415 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
大模型接入点(模型档案)交互式管理工具
不用手写 config.json:菜单式列出 / 添加 / 删除 / 连通性验证
`models` 节的模型接入点(OpenAI Chat Completions 兼容端点:
DeepSeek / Kimi / Qwen / GLM / 本地 Ollama 等)。
用法:
python model_config.py # 交互式菜单
python model_config.py --list # 非交互:列出当前接入点
安全约定(与 aibox 审计一致):API Key 一律不写入配置文件——
只保存环境变量名(api_key_env),或留空表示免密本地端点。
"""
import argparse
import os
import re
import sys
from typing import Dict, List, Optional
# 允许从仓库根目录或 aibox/ 目录启动
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from common import ConfigManager # noqa: E402
PROFILE_NAME_RE = re.compile(r"^[A-Za-z0-9_-]+$")
# 常见厂商端点提示(首行是默认建议值)
PROVIDER_HINTS = {
"DeepSeek": "https://api.deepseek.com/v1/chat/completions",
"Kimi (Moonshot)": "https://api.moonshot.cn/v1/chat/completions",
"Qwen (阿里云百炼)": "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions",
"GLM (智谱)": "https://open.bigmodel.cn/api/paas/v4/chat/completions",
"Ollama 本地": "http://localhost:11434/v1/chat/completions",
}
# ==================== 核心逻辑(可单测,不依赖 input) ====================
def get_models(config: ConfigManager) -> Dict:
"""取 models 节(不存在则初始化为空 dict)。"""
models = config.get("models") or {}
if not isinstance(models, dict):
models = {}
return models
def list_profiles(config: ConfigManager) -> List[Dict]:
"""列出所有档案的归一化展示数据。"""
models = get_models(config)
default = config.get("api.default_model") or ""
profiles = []
for name, profile in models.items():
if not isinstance(profile, dict):
continue
profiles.append({
"name": name,
"is_default": name == default,
"display_name": profile.get("display_name") or name,
"api_url": profile.get("api_url", ""),
"model": profile.get("model", ""),
"api_key_env": profile.get("api_key_env", ""),
"reasoning_enabled": bool(profile.get("reasoning_enabled", False)),
"temperature": profile.get("temperature"),
"timeout": profile.get("timeout"),
"fallback": profile.get("fallback") or [],
})
return profiles
def validate_new_profile(config: ConfigManager, name: str, api_url: str,
model: str) -> Optional[str]:
"""校验新档案字段,返回错误信息(合法返回 None)。"""
if not name:
return "档案名不能为空"
if not PROFILE_NAME_RE.match(name):
return f"档案名只能包含字母/数字/下划线/短横线(当前: {name})"
models = get_models(config)
if name in models:
return f"档案名 '{name}' 已存在"
if not api_url or not str(api_url).strip().lower().startswith(("http://", "https://")):
return "API 地址必须是 http:// 或 https:// 开头的完整 URL"
if not model or not str(model).strip():
return "模型名不能为空(如 deepseek-chat / gpt-4o / qwen-plus)"
return None
def add_profile(config: ConfigManager, name: str, api_url: str, model: str,
api_key_env: str = "", display_name: str = None,
temperature: float = None, timeout: int = None,
reasoning_enabled: bool = False,
fallback: Optional[List[str]] = None,
set_default: bool = False) -> List[str]:
"""添加档案。返回提示信息列表;字段非法时返回带 ❌ 的错误信息。"""
err = validate_new_profile(config, name, api_url, model)
if err:
return [f"❌ {err}"]
models = get_models(config)
profile = {
"api_url": str(api_url).strip(),
"model": str(model).strip(),
}
if api_key_env:
profile["api_key_env"] = api_key_env.strip()
else:
# 空串 = 免密本地端点(Ollama/vLLM),显式声明
profile["api_key_env"] = ""
if display_name:
profile["display_name"] = display_name.strip()
if temperature is not None:
try:
profile["temperature"] = max(0.0, min(2.0, float(temperature)))
except (TypeError, ValueError):
return ["❌ 温度必须是 0.0~2.0 的数字"]
if timeout is not None:
try:
profile["timeout"] = max(5, int(timeout))
except (TypeError, ValueError):
return ["❌ 超时必须是整数秒"]
if reasoning_enabled:
profile["reasoning_enabled"] = True
if fallback:
# 只保留已存在的档案,避免悬空引用(L4a 启动期硬性报错)
known = set(models.keys())
invalid = [f for f in fallback if f not in known or f == name]
if invalid:
return [f"❌ 失败转移包含不存在的档案或自身: {invalid},请重新选择"]
profile["fallback"] = list(fallback)
models[name] = profile
config._config["models"] = models
if set_default:
config._config.setdefault("api", {})["default_model"] = name
saved = config._save_config()
msgs = [f"✅ 接入点 '{name}' 已添加({profile['api_url']},模型 {profile['model']})"]
if not saved:
msgs.append("⚠️ 保存失败(仅本次运行生效,重启后丢失)——请检查配置文件目录权限")
if set_default:
msgs.append(f"⭐ 已设为默认接入点(--model 不指定时使用)")
if not api_key_env:
msgs.append("ℹ️ 免密端点:请求不携带 API Key(适用于 Ollama/vLLM 等本地服务)")
else:
msgs.append(f"🔑 Key 从环境变量 {api_key_env} 读取(未设置时该档案不可用)")
return msgs
def delete_profile(config: ConfigManager, name: str) -> List[str]:
"""删除档案,并自动清理其他档案 fallback 中的悬空引用。"""
models = get_models(config)
if name not in models:
return [f"❌ 档案 '{name}' 不存在"]
del models[name]
# 清理其他档案对该档案的 fallback 引用(悬空引用会导致启动期报错)
cleaned = []
for other, profile in models.items():
if not isinstance(profile, dict):
continue
fb = profile.get("fallback") or []
if name in fb:
profile["fallback"] = [f for f in fb if f != name]
cleaned.append(other)
config._config["models"] = models
# 若删除的是默认档案,清除默认标记(回退旧 api 节语义)
if config.get("api.default_model") == name:
config._config.setdefault("api", {}).pop("default_model", None)
saved = config._save_config()
msgs = [f"✅ 接入点 '{name}' 已删除"]
if cleaned:
msgs.append(f"ℹ️ 已同时清理 {len(cleaned)} 个档案的失败转移引用: {', '.join(cleaned)}")
if not saved:
msgs.append("⚠️ 保存失败——请检查配置文件目录权限")
return msgs
def test_profile(config: ConfigManager, name: str, logger=None) -> str:
"""连通性验证:向该档案端点发一次最小请求。"""
models = get_models(config)
if name not in models:
return f"❌ 档案 '{name}' 不存在"
try:
from llm_client import create_llm_client
client = create_llm_client(config, api_key=None, logger=logger, model=name)
result = client.chat_completion(
[{"role": "user", "content": "ping"}], max_tokens=5)
if result is None or not str(result).strip():
return f"❌ 档案 '{name}' 验证失败:端点返回空响应(多为 4xx 认证/参数错误)"
return f"✅ 档案 '{name}' 连通正常:{str(result).strip()[:60]}"
except Exception as e:
return f"❌ 档案 '{name}' 验证失败:{str(e)}"
# ==================== 交互式菜单 ====================
def _ask(prompt: str, default: str = "") -> str:
if default:
val = input(f"{prompt}(回车默认: {default}): ").strip()
return val if val else default
return input(f"{prompt}: ").strip()
def _ask_yes_no(prompt: str, default: bool = False) -> bool:
hint = "y/n" if not default else "Y/n"
while True:
val = input(f"{prompt} [{hint}] ").strip().lower()
if not val:
return default
if val in ("y", "yes", "是"):
return True
if val in ("n", "no", "否"):
return False
print(" 请输入 y 或 n")
def _choose(prompt: str, options: List[str], multi: bool = False) -> List[str]:
"""编号选择(单选或多选)。多选用逗号分隔,直接回车 = 不选。"""
for i, opt in enumerate(options, 1):
print(f" {i}. {opt}")
if multi:
while True:
raw = input(f"{prompt}(多选,逗号分隔,直接回车不选): ").strip()
if not raw:
return []
try:
idxs = [int(x) for x in raw.replace(",", ",").split(",") if x.strip()]
except ValueError:
print(" 请输入数字编号,逗号分隔")
continue
if all(1 <= i <= len(options) for i in idxs):
return [options[i - 1] for i in idxs]
print(f" 编号超出范围(1~{len(options)})")
else:
while True:
raw = input(f"{prompt}(1~{len(options)},直接回车不选): ").strip()
if not raw:
return []
try:
idx = int(raw)
except ValueError:
print(" 请输入数字编号")
continue
if 1 <= idx <= len(options):
return [options[idx - 1]]
print(f" 编号超出范围(1~{len(options)})")
def _print_profiles(profiles: List[Dict]):
if not profiles:
print(" (当前没有任何接入点。旧版 api 节仍可用,或选 2 添加)")
return
for p in profiles:
mark = " ⭐默认" if p["is_default"] else ""
key_desc = f"环境变量 {p['api_key_env']}" if p["api_key_env"] else "免密"
fb = f",失败转移 → {', '.join(p['fallback'])}" if p["fallback"] else ""
print(f" {p['name']}{mark} [{p['display_name']}]")
print(f" 地址: {p['api_url']} 模型: {p['model']}")
print(f" Key: {key_desc} 温度: {p['temperature'] or '默认'} 超时: {p['timeout'] or '默认'}{fb}")
def _add_flow(config: ConfigManager):
print("\n── 添加接入点 ──")
print("常见厂商端点参考:")
for k, v in PROVIDER_HINTS.items():
print(f" {k}: {v}")
name = _ask("档案名(英文,如 deepseek/kimi/local)")
if not name:
print("❌ 已取消(档案名不能为空)")
return
if not PROFILE_NAME_RE.match(name):
print(f"❌ 档案名只能包含字母/数字/下划线/短横线")
return
if name in get_models(config):
print(f"❌ 档案名 '{name}' 已存在")
return
api_url = _ask("API 地址")
model = _ask("模型名(如 deepseek-chat / qwen-plus)")
print("Key 方式:")
print(" 1. 环境变量名(推荐,如 DEEPSEEK_API_KEY——Key 不落盘,重启不丢)")
print(" 2. 免密本地端点(如 Ollama,不需要 Key)")
key_mode = _ask("选择 [1/2]", "1")
api_key_env = ""
if key_mode == "1":
api_key_env = _ask("环境变量名", "DEEPSEEK_API_KEY")
elif key_mode != "2":
print("❌ 已取消(Key 方式无效)")
return
display_name = _ask("显示名(可选,回车用档案名)")
temperature_raw = _ask("温度 0.0~2.0(可选,越低越确定)")
timeout_raw = _ask("超时秒数(可选)")
reasoning = _ask_yes_no("启用思考模式?", default=False)
profiles = list_profiles(config)
fallback = []
if profiles:
print("失败转移:主接入点故障时按顺序尝试的备用接入点(可选)")
fallback = _choose("选择备用接入点", [p["name"] for p in profiles], multi=True)
set_default = _ask_yes_no("设为默认接入点?", default=(len(profiles) == 0))
print("\n── 确认 ──")
print(f" 档案名: {name}")
print(f" 地址: {api_url} 模型: {model}")
print(f" Key: {'环境变量 ' + api_key_env if api_key_env else '免密(不携带)'}")
if display_name:
print(f" 显示名: {display_name}")
if temperature_raw:
print(f" 温度: {temperature_raw}")
if timeout_raw:
print(f" 超时: {timeout_raw}s")
if reasoning:
print(" 思考模式: 开启")
if fallback:
print(f" 失败转移: {', '.join(fallback)}")
if not _ask_yes_no("确认保存?", default=True):
print("已取消")
return
msgs = add_profile(config, name, api_url, model,
api_key_env=api_key_env, display_name=display_name,
temperature=float(temperature_raw) if temperature_raw else None,
timeout=int(timeout_raw) if timeout_raw else None,
reasoning_enabled=reasoning, fallback=fallback,
set_default=set_default)
for m in msgs:
print(m)
def _delete_flow(config: ConfigManager):
profiles = list_profiles(config)
if not profiles:
print("\n(当前没有任何接入点)")
return
print("\n── 删除接入点 ──")
picked = _choose("选择要删除的接入点", [p["name"] for p in profiles])
if not picked:
print("已取消")
return
name = picked[0]
if not _ask_yes_no(f"确认删除 '{name}'?(其他档案的失败转移引用会自动清理)", default=False):
print("已取消")
return
for m in delete_profile(config, name):
print(m)
def _test_flow(config: ConfigManager):
profiles = list_profiles(config)
if not profiles:
print("\n(当前没有任何接入点)")
return
print("\n── 连通性验证 ──")
picked = _choose("选择要验证的接入点", [p["name"] for p in profiles])
if not picked:
print("已取消")
return
print(" 正在请求端点(最长 20 秒)...")
print(test_profile(config, picked[0]))
def run_interactive(config: ConfigManager = None):
"""交互式主循环。config 可注入(测试用)。"""
config = config or ConfigManager()
config_path = getattr(config, "_config_path", None) or "(默认位置)"
print("=" * 56)
print(" aibox 大模型接入点管理工具")
print(f" 配置文件: {config_path}")
print(" 说明:API Key 只存环境变量名,不落盘;免密端点适合 Ollama 等本地服务")
print("=" * 56)
while True:
print("\n请选择操作:")
print(" 1. 📋 列出接入点")
print(" 2. ➕ 添加接入点")
print(" 3. 🗑️ 删除接入点")
print(" 4. 🔍 验证接入点连通性")
print(" 0. 退出")
choice = input("> ").strip()
if choice == "1":
_print_profiles(list_profiles(config))
elif choice == "2":
_add_flow(config)
elif choice == "3":
_delete_flow(config)
elif choice == "4":
_test_flow(config)
elif choice in ("0", "q", "exit", "退出"):
print("再见")
return
else:
print("无效选择,请输入 0~4")
def main():
parser = argparse.ArgumentParser(description="aibox 大模型接入点管理工具")
parser.add_argument("--list", action="store_true", help="非交互:列出当前接入点")
args = parser.parse_args()
config = ConfigManager()
if args.list:
_print_profiles(list_profiles(config))
return
run_interactive(config)
if __name__ == "__main__":
main()
+3
View File
@@ -9,6 +9,7 @@ subsystems - aibox 子系统模块(包)
- skill_manager.py : SkillManager - skill_manager.py : SkillManager
Wave 2: Wave 2:
- conversation_history.py : ConversationHistory(对话历史管理) - conversation_history.py : ConversationHistory(对话历史管理)
- conversation_compressor.py : ConversationCompressor(对话压缩,注入 llm_client)
依赖方向:common <- subsystems <- aibox 依赖方向:common <- subsystems <- aibox
不反向依赖 aibox 任何符号。 不反向依赖 aibox 任何符号。
@@ -22,6 +23,7 @@ from .prompt_manager import PromptManager
from .db import MemoryDatabase, MemoDatabase from .db import MemoryDatabase, MemoDatabase
from .skill_manager import SkillManager from .skill_manager import SkillManager
from .conversation_history import ConversationHistory from .conversation_history import ConversationHistory
from .conversation_compressor import ConversationCompressor
__all__ = [ __all__ = [
"CommandExecutor", "CommandExecutor",
@@ -31,4 +33,5 @@ __all__ = [
"MemoDatabase", "MemoDatabase",
"SkillManager", "SkillManager",
"ConversationHistory", "ConversationHistory",
"ConversationCompressor",
] ]
+524
View File
@@ -0,0 +1,524 @@
#!/usr/bin/env python3
"""
subsystems.conversation_compressor - 对话压缩子系统
抽离自 aibox.py,负责对话上下文压缩:消息条数或 Token 数达到阈值时,
将早期对话(含工具调用信息)经 LLM 压缩为摘要,保留最近消息。
依赖注入:构造时传入 llm_client(llm_client.LLMClient 实例)而非 AI
引擎实例,避免与 DeepSeekChat 形成循环引用;依赖方向 common <- subsystems。
"""
import re
from typing import Dict, List, Optional, Tuple
from common import constants, i18n, Logger, ConfigManager
class ConversationCompressor:
"""对话压缩管理器 - 优先压缩较早的消息,将tool调用信息一并压缩"""
def __init__(self, llm_client=None, logger: Logger = None):
self.llm_client = llm_client
self.logger = logger or Logger("Compressor")
self.config = ConfigManager()
# 从配置获取最小值
self.min_messages_to_compress = constants.COMPRESSOR_MIN_MESSAGES
# 权重衰减因子:从配置获取,设为0.6使加权计算更合理
self.weight_decay_factor = 0.6 # 使用更高的衰减因子
def estimate_tokens(self, messages: List[Dict]) -> int:
"""估算消息的token数"""
total_tokens = 0
for msg in messages:
content = msg.get("content", "")
if content:
chinese_chars = len([c for c in content if '\u4e00' <= c <= '\u9fff'])
english_words = len(re.findall(r'[a-zA-Z]+', content))
other_chars = len(content) - chinese_chars - english_words
estimated = chinese_chars * 1.5 + english_words * 1.3 + other_chars * 0.8
total_tokens += estimated
total_tokens += 10
if msg.get("tool_calls"):
# 工具调用也计算token
for tc in msg.get("tool_calls", []):
func = tc.get("function", {})
if func.get("name"):
total_tokens += len(func.get("name", "")) * 1.5
if func.get("arguments"):
total_tokens += len(func.get("arguments", "")) * 0.8
total_tokens += 50
return int(total_tokens)
def should_compress(self, messages: List[Dict]) -> Tuple[bool, str]:
"""检查是否需要压缩对话 - 消息条数或 Token 数任一达到阈值即压缩"""
if not constants.COMPRESS_ENABLED:
return False, "压缩功能已禁用"
conversation_messages = [m for m in messages if m.get("role") != "system"]
# 优先检查消息条数是否达到阈值
message_count = len(conversation_messages)
if message_count >= constants.COMPRESS_MESSAGE_THRESHOLD:
print(f"⚠️ {i18n.get('compress_threshold_reached', message_count)}")
return True, f"消息数({message_count})超过阈值({constants.COMPRESS_MESSAGE_THRESHOLD})"
# 再检查 Token 数是否达到阈值(长文档/长工具输出场景)
token_count = self.estimate_tokens(conversation_messages)
if token_count >= constants.COMPRESS_TOKEN_THRESHOLD:
print(f"⚠️ {i18n.get('compress_token_threshold_reached', token_count)}")
return True, f"Token数({token_count})超过阈值({constants.COMPRESS_TOKEN_THRESHOLD})"
return False, f"消息数({message_count})与Token数({token_count})均未达到阈值"
def _calculate_target_count(self, current_count: int, threshold: int, keep_ratio: float = 0.7) -> int:
"""
计算目标消息数量 - 压缩到阈值的 keep_ratio 倍
Args:
current_count: 当前消息数量
threshold: 压缩阈值
keep_ratio: 压缩后保留的比例(相对于阈值),默认0.7表示压缩到阈值的70%
Returns:
目标消息数量
"""
# 压缩到阈值以下,保留阈值的 keep_ratio 倍
target = int(threshold * keep_ratio)
# 确保至少有 min_messages_to_compress 条消息
target = max(target, self.min_messages_to_compress)
# 确保不超过当前数量
target = min(target, current_count - 1)
return target
def _find_compressible_block_by_count(self, messages: List[Dict], target_count: int) -> Tuple[int, int]:
"""
根据目标消息数量找到可压缩块 - 不保护tool消息,直接压缩早期消息
Args:
messages: 消息列表
target_count: 目标消息数量
Returns:
(start, end) 压缩块的起始和结束索引
"""
if len(messages) <= target_count:
return 0, 0
# 计算需要压缩的消息数量
messages_to_remove = len(messages) - target_count
# 确定要保留的最近消息数量
keep_recent = constants.COMPRESS_KEEP_RECENT
if keep_recent > 0:
keep_recent = max(keep_recent, 10) # 至少保留最近10条
# 压缩块应该从最早的消息开始,但需要保留前几轮对话
# 计算最早可以开始压缩的位置(跳过前2轮对话)
start_min = 0
# 尝试找到前2个对话轮次结束的位置
conversation_turns = self._build_conversation_turns(messages)
if len(conversation_turns) >= 2:
# 前2轮结束的位置
first_turn = conversation_turns[0]
second_turn = conversation_turns[1]
# 取第二轮结束的位置
if second_turn["assistant_index"] is not None:
start_min = second_turn["assistant_index"] + 1
else:
start_min = second_turn["user_index"] + 1
# 压缩结束的位置 = 总消息数 - 保留的最近消息数
end_max = len(messages) - keep_recent
# 确保压缩块有效
if end_max <= start_min + 2:
# 如果保护区域太大,尝试压缩更多
end_max = len(messages) - max(keep_recent // 2, 5)
# 计算需要压缩的块大小
block_size = min(messages_to_remove + 10, end_max - start_min)
block_size = max(block_size, 10) # 至少压缩10条
# 压缩块从 start_min 开始
start = start_min
end = min(start + block_size, end_max)
# 确保压缩块至少2条消息
if end - start < 2:
# 如果块太小,尝试从更早的位置开始
start = max(0, end_max - block_size)
end = end_max
self.logger.debug(f"找到压缩块: [{start}:{end}], 移除{end-start}条消息")
return start, end
def _build_conversation_turns(self, messages: List[Dict]) -> List[Dict]:
"""构建对话轮次"""
conversation_turns = []
i = 0
while i < len(messages):
msg = messages[i]
role = msg.get("role")
# 跳过系统消息
if role == "system":
i += 1
continue
# 遇到用户消息,开始一个新的轮次
if role == "user":
turn = {"user_index": i, "assistant_index": None, "tool_indices": []}
# 向后查找对应的助手消息和工具消息
j = i + 1
while j < len(messages):
next_msg = messages[j]
next_role = next_msg.get("role")
# 跳过系统消息
if next_role == "system":
j += 1
continue
# 找到助手消息
if next_role == "assistant":
turn["assistant_index"] = j
# 检查这个助手消息是否有 tool_calls
if next_msg.get("tool_calls"):
# 收集这个助手消息的 tool_call_ids
tool_call_ids = {tc.get("id") for tc in next_msg.get("tool_calls", []) if tc.get("id")}
# 继续查找对应的 tool 消息
k = j + 1
while k < len(messages) and len(turn["tool_indices"]) < len(tool_call_ids):
tool_msg = messages[k]
if tool_msg.get("role") == "tool":
tool_call_id = tool_msg.get("tool_call_id")
if tool_call_id and tool_call_id in tool_call_ids:
turn["tool_indices"].append(k)
k += 1
elif tool_msg.get("role") == "user":
break
else:
k += 1
break
if next_role == "user":
break
j += 1
conversation_turns.append(turn)
i += 1
else:
i += 1
return conversation_turns
def _is_range_safe(self, messages: List[Dict], start: int, end: int, protected: set) -> bool:
"""检查消息范围是否安全 - 简化版,不检查tool完整性"""
return True # 不保护tool消息,允许压缩
def _get_protected_indices(self, messages: List[Dict]) -> set:
"""
找出所有受保护的消息索引 - 简化版,只保护最近的对话轮次
"""
protected_indices = set()
# ========== 1. 保护最近的消息(由配置决定) ==========
keep_recent = constants.COMPRESS_KEEP_RECENT
if keep_recent > 0:
recent_start = max(0, len(messages) - keep_recent)
for i in range(recent_start, len(messages)):
protected_indices.add(i)
# ========== 2. 保护前2个对话轮次 ==========
conversation_turns = self._build_conversation_turns(messages)
if len(conversation_turns) >= 2:
# 保护第1轮
first_turn = conversation_turns[0]
protected_indices.add(first_turn["user_index"])
if first_turn["assistant_index"] is not None:
protected_indices.add(first_turn["assistant_index"])
for tool_idx in first_turn["tool_indices"]:
protected_indices.add(tool_idx)
# 保护第2轮
second_turn = conversation_turns[1]
protected_indices.add(second_turn["user_index"])
if second_turn["assistant_index"] is not None:
protected_indices.add(second_turn["assistant_index"])
for tool_idx in second_turn["tool_indices"]:
protected_indices.add(tool_idx)
# 调试日志
if self.logger.level >= 3:
self.logger.debug(f"受保护的消息索引: {sorted(protected_indices)}")
return protected_indices
def _build_compression_prompt(self, messages: List[Dict]) -> str:
"""构建压缩提示词 - 包含工具调用信息"""
conversation_lines = []
for msg in messages:
role = msg.get("role")
content = msg.get("content", "")
if role == "user":
if content:
conversation_lines.append(f"用户: {content}")
elif role == "assistant":
if content:
conversation_lines.append(f"助手: {content}")
# 包含工具调用信息
tool_calls = msg.get("tool_calls", [])
for tc in tool_calls:
func = tc.get("function", {})
tool_name = func.get("name", "")
arguments = func.get("arguments", "")
if tool_name:
if arguments:
conversation_lines.append(f"[调用工具: {tool_name}({arguments[:100]})]")
else:
conversation_lines.append(f"[调用工具: {tool_name}]")
elif role == "tool":
if content:
useful = self._extract_useful_content(content)
if useful:
conversation_lines.append(f"[工具结果: {useful}]")
conversation_text = "\n".join(conversation_lines)
max_input_chars = constants.COMPRESSOR_MAX_INPUT_CHARS
if len(conversation_text) > max_input_chars:
conversation_text = conversation_text[:max_input_chars] + "\n...(内容过长,已截断)..."
prompt = f"""请对以下对话内容进行智能压缩和总结。要求:
1. **提取核心信息**:对话的主要主题、关键问题、重要结论、用户需求
2. **保留重要细节**:用户的重要要求、AI的重要回复、达成的共识
3. **保留工具调用摘要**:简要说明使用了什么工具,得到了什么关键结果
4. **结构化输出**:使用清晰的段落结构
对话内容:
---
{conversation_text}
---
请输出压缩后的对话摘要:"""
return prompt
def _extract_useful_content(self, content: str) -> str:
"""从工具返回内容中提取有用信息"""
if not content:
return ""
if "✅" in content:
lines = content.split("\n")
for line in lines[:3]:
if "✅" in line:
return line.strip()
if "❌" in content:
lines = content.split("\n")
for line in lines[:2]:
if "❌" in line:
return line.strip()
if len(content) > 100:
return content[:100] + "..."
return content
def _compress_via_llm(self, messages: List[Dict]) -> Optional[str]:
"""通过语言模型压缩消息(经由注入的客户端抽象层调用,支持多厂商)"""
if not messages or not self.llm_client:
return None
prompt = self._build_compression_prompt(messages)
print(f"📦 {i18n.get('conversation_compress')}: 正在压缩 {len(messages)} 条消息...")
try:
summary = self.llm_client.chat_completion(
[
{"role": "system", "content": "你是一个专业的对话摘要助手,擅长提取对话核心信息并进行结构化总结。请保留工具调用的关键结果。"},
{"role": "user", "content": prompt}
],
temperature=0.3,
max_tokens=constants.COMPRESSOR_LLM_MAX_TOKENS
)
return summary
except Exception as e:
self.logger.error(f"语言模型压缩异常: {e}")
return None
def _validate_message_structure(self, messages: List[Dict]) -> List[Dict]:
"""验证并修复消息结构,移除孤立的 tool 消息"""
if not messages:
return messages
# 找出所有 assistant 消息的 tool_call_ids
assistant_tool_ids = set()
for msg in messages:
if msg.get("role") == "assistant" and msg.get("tool_calls"):
for tc in msg.get("tool_calls", []):
tc_id = tc.get("id")
if tc_id:
assistant_tool_ids.add(tc_id)
# 过滤掉没有对应 assistant 的 tool 消息
filtered = []
for msg in messages:
if msg.get("role") == "tool":
tool_call_id = msg.get("tool_call_id")
if tool_call_id and tool_call_id not in assistant_tool_ids:
print(f" ⚠️ 移除孤立的 tool 消息: {tool_call_id}")
continue
filtered.append(msg)
return filtered
def compress_conversation(self, messages: List[Dict]) -> List[Dict]:
"""
压缩对话内容 - 优先压缩较早的消息,包含tool调用信息一并压缩
当消息条数或 Token 数超过阈值时,压缩到阈值以下
"""
if not messages:
return messages
# 分离系统消息和对话消息
system_messages = []
conversation_messages = []
for msg in messages:
if msg.get("role") == "system":
system_messages.append(msg)
else:
conversation_messages.append(msg)
if not conversation_messages:
return system_messages
original_count = len(conversation_messages)
original_tokens = self.estimate_tokens(conversation_messages)
threshold = constants.COMPRESS_MESSAGE_THRESHOLD
print(f"\n📊 对话压缩检查:")
print(f" 当前消息数: {original_count} 条 (阈值: {threshold} 条)")
print(f" 当前Token数: {original_tokens} 个")
# 计算目标消息数量 - 压缩到阈值的70%
target_count = self._calculate_target_count(original_count, threshold, keep_ratio=0.7)
if original_count < threshold:
# 消息数未超阈值(由 Token 阈值触发):按比例压缩早期消息
target_count = max(self.min_messages_to_compress, original_count // 2)
print(f" Token 触发压缩:目标消息数改为 {target_count} 条 (当前的一半)")
else:
print(f" 目标消息数: {target_count} 条 (压缩后应少于阈值)")
# 如果目标数量大于等于当前数量,不需要压缩
if target_count >= original_count:
print(f" 目标消息数大于等于当前,跳过压缩")
return messages
# 计算需要压缩的消息数量
messages_to_compress = original_count - target_count
print(f" 需要压缩的消息数量: {messages_to_compress} 条")
# 根据消息数量查找可压缩块
compress_start, compress_end = self._find_compressible_block_by_count(
conversation_messages, target_count
)
if compress_end - compress_start >= 2:
compressible = conversation_messages[compress_start:compress_end]
compress_tokens = self.estimate_tokens(compressible)
compress_count = len(compressible)
print(f" 压缩块范围: [{compress_start}:{compress_end}], 共 {compress_count} 条消息")
print(f" 压缩块token数: {compress_tokens}")
# 如果压缩块大小仍然不够,尝试扩大压缩块
if compress_count < messages_to_compress:
print(f" 压缩块大小不足({compress_count} < {messages_to_compress}),扩大压缩块...")
# 扩大压缩块
additional = messages_to_compress - compress_count + 5
new_start = max(0, compress_start - additional // 2)
new_end = min(len(conversation_messages), compress_end + additional // 2)
compress_start, compress_end = new_start, new_end
compressible = conversation_messages[compress_start:compress_end]
compress_count = len(compressible)
print(f" 扩大后压缩块: [{compress_start}:{compress_end}], 共 {compress_count} 条消息")
else:
print(f" 未找到合适的压缩块,使用默认压缩策略")
# 使用默认压缩策略:压缩最早的一半消息,保留最近的消息
keep_recent = constants.COMPRESS_KEEP_RECENT
compress_end = len(conversation_messages) - keep_recent
compress_start = max(0, compress_end - messages_to_compress - 5)
compress_start = max(compress_start, 0)
compressible = conversation_messages[compress_start:compress_end]
compress_count = len(compressible)
print(f" 默认压缩块: [{compress_start}:{compress_end}], 共 {compress_count} 条消息")
# 确保压缩块有足够多的消息
if compress_count < 5:
print(f" 压缩块太小({compress_count}条),跳过压缩")
return messages
# 调用语言模型压缩
summary = self._compress_via_llm(compressible)
if not summary:
print(f" ❌ 压缩失败,保留原始对话")
return messages
# 构建压缩后的消息列表
compressed_result = []
# 添加压缩块之前的消息
compressed_result.extend(conversation_messages[:compress_start])
# 插入压缩摘要(作为系统消息)
compressed_result.append({
"role": "system",
"content": i18n.get('compressed_summary', summary)
})
# 添加压缩块之后的消息(包括最近的消息)
compressed_result.extend(conversation_messages[compress_end:])
# 验证并修复消息结构
compressed_result = self._validate_message_structure(compressed_result)
# 合并系统消息
final_messages = system_messages + compressed_result
# 统计结果
final_count = len([m for m in final_messages if m.get("role") != "system"])
final_tokens = self.estimate_tokens([m for m in final_messages if m.get("role") != "system"])
print(f"\n✅ {i18n.get('conversation_compressed', original_count, final_count)}")
# L6:压缩块全空时 original_tokens 可能为 0,除零防护
print(f" Token: {original_tokens} -> {final_tokens} (压缩率: {final_tokens / max(original_tokens, 1):.1%})")
# 检查是否压缩到阈值以下
if final_count >= threshold:
print(f" ⚠️ 警告: 压缩后消息数({final_count})仍大于等于阈值({threshold})")
return final_messages
+31 -8
View File
@@ -15,6 +15,7 @@ subsystems.conversation_history - 对话历史管理器
""" """
import json import json
import os
import time import time
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -61,14 +62,26 @@ class ConversationHistory:
try: try:
with open(self.history_file, 'r', encoding='utf-8') as f: with open(self.history_file, 'r', encoding='utf-8') as f:
return json.load(f) return json.load(f)
except: except Exception as e:
# N16:收窄为 Exception——裸 except 连 KeyboardInterrupt/SystemExit
# 也吞掉;损坏时重置为空索引并告警
self.logger.warning(f"历史索引文件损坏,已重置: {e}")
return {"conversations": [], "last_id": 0} return {"conversations": [], "last_id": 0}
return {"conversations": [], "last_id": 0} return {"conversations": [], "last_id": 0}
@staticmethod
def _atomic_write_json(path: Path, data) -> None:
"""原子写 JSON(I 批,审计 2026-08-18):先写同目录临时文件再
os.replace——此前 'w' 直写,进程中断/崩溃留半截 JSON(current 损坏
后仅静默置 None,历史内容丢失)"""
tmp = path.with_suffix(path.suffix + ".tmp")
with open(tmp, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
os.replace(tmp, path)
def _save_history_index(self): def _save_history_index(self):
"""保存历史索引""" """保存历史索引"""
with open(self.history_file, 'w', encoding='utf-8') as f: self._atomic_write_json(self.history_file, self.history_index)
json.dump(self.history_index, f, ensure_ascii=False, indent=2)
def _load_current_conversation(self): def _load_current_conversation(self):
"""加载当前的对话(如果有)""" """加载当前的对话(如果有)"""
@@ -83,8 +96,7 @@ class ConversationHistory:
def save_current_conversation(self): def save_current_conversation(self):
"""保存当前对话""" """保存当前对话"""
if self.current_conversation: if self.current_conversation:
with open(self.current_file, 'w', encoding='utf-8') as f: self._atomic_write_json(self.current_file, self.current_conversation)
json.dump(self.current_conversation, f, ensure_ascii=False, indent=2)
def start_new_conversation(self, topic: str = None) -> Dict: def start_new_conversation(self, topic: str = None) -> Dict:
"""开始新对话""" """开始新对话"""
@@ -132,12 +144,17 @@ class ConversationHistory:
importance=2 importance=2
) )
# N16:归档文件名加序号防同秒覆盖——DATE_FORMAT 精度为秒,
# 同一秒内两次归档(如测试/快速重置)会静默覆盖前一份
timestamp = datetime.now().strftime(constants.DATE_FORMAT) timestamp = datetime.now().strftime(constants.DATE_FORMAT)
archive_filename = f"conversation_{timestamp}.json" archive_filename = f"conversation_{timestamp}.json"
counter = 1
while (self.archive_dir / archive_filename).exists():
archive_filename = f"conversation_{timestamp}_{counter}.json"
counter += 1
archive_path = self.archive_dir / archive_filename archive_path = self.archive_dir / archive_filename
with open(archive_path, 'w', encoding='utf-8') as f: self._atomic_write_json(archive_path, self.current_conversation)
json.dump(self.current_conversation, f, ensure_ascii=False, indent=2)
self.history_index["conversations"].append({ self.history_index["conversations"].append({
"id": self.current_conversation["id"], "id": self.current_conversation["id"],
@@ -341,7 +358,13 @@ class ConversationHistory:
if file_name == constants.CURRENT_CONVERSATION_FILE and self.current_conversation: if file_name == constants.CURRENT_CONVERSATION_FILE and self.current_conversation:
return self.current_conversation return self.current_conversation
file_path = self.archive_dir / file_name # I 批(审计 2026-08-18):file_name 目前无 LLM 可控调用面,但一旦
# 接入即成为任意 .json 读取通道——realpath 解析软链后再做目录前缀校验
archive_root = os.path.normcase(os.path.realpath(str(self.archive_dir)))
target = os.path.normcase(os.path.realpath(str(self.archive_dir / file_name)))
if not target.startswith(archive_root + os.sep):
return None
file_path = Path(target)
if file_path.exists(): if file_path.exists():
with open(file_path, 'r', encoding='utf-8') as f: with open(file_path, 'r', encoding='utf-8') as f:
return json.load(f) return json.load(f)
+202 -69
View File
@@ -36,6 +36,8 @@ class MemoryDatabase:
def _init_database(self): def _init_database(self):
"""初始化数据库表""" """初始化数据库表"""
with sqlite3.connect(self.db_path) as conn: with sqlite3.connect(self.db_path) as conn:
# WAL 模式:读写并发不互斥,缓解多 CLI 进程并发写锁等待
conn.execute("PRAGMA journal_mode=WAL")
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(''' cursor.execute('''
CREATE TABLE IF NOT EXISTS memories ( CREATE TABLE IF NOT EXISTS memories (
@@ -51,13 +53,57 @@ class MemoryDatabase:
) )
''') ''')
cursor.execute(''' self._init_fts_table(conn)
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts
USING fts5(topic, summary, content, tags, content=memories)
''')
conn.commit() conn.commit()
def _fts_trigram_supported(self, conn) -> bool:
"""检测当前 SQLite 是否支持 trigram 分词器(需 SQLite >= 3.34)"""
try:
conn.execute("CREATE VIRTUAL TABLE temp.__trigram_probe USING fts5(x, tokenize='trigram')")
conn.execute("DROP TABLE temp.__trigram_probe")
return True
except sqlite3.OperationalError:
return False
def _init_fts_table(self, conn):
"""初始化 FTS5 全文索引表。
优先使用 trigram 分词器(中文可检索,unicode61 对中文不分词);
检测到旧 unicode61 表时自动迁移:DROP 重建后从 memories 表回灌全量数据。
环境不支持 trigram 时退回 unicode61 建表,中文搜索由 LIKE 兜底。
"""
cursor = conn.cursor()
trigram_ok = self._fts_trigram_supported(conn)
tokenize_opt = ", tokenize='trigram'" if trigram_ok else ""
create_sql = (
"CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts "
f"USING fts5(topic, summary, content, tags, content=memories{tokenize_opt})"
)
row = cursor.execute(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='memories_fts'"
).fetchone()
need_backfill = False
if row is None:
need_backfill = True # 新库(memories 可能已有数据,回灌无害)
elif trigram_ok and "trigram" not in (row[0] or ""):
# 旧 unicode61 表 → 迁移为 trigram
cursor.execute("DROP TABLE memories_fts")
need_backfill = True
cursor.execute(create_sql)
if need_backfill:
count = cursor.execute('''
INSERT INTO memories_fts (rowid, topic, summary, content, tags)
SELECT id, topic, summary, COALESCE(content, ''), COALESCE(tags, '')
FROM memories
''').rowcount
if count:
self.logger.info(f"FTS5 索引已使用 trigram 分词重建并回灌 {count} 条记忆")
def add_memory(self, topic: str, summary: str, content: str = None, def add_memory(self, topic: str, summary: str, content: str = None,
start_time: str = None, end_time: str = None, start_time: str = None, end_time: str = None,
tags: List[str] = None, importance: int = None) -> int: tags: List[str] = None, importance: int = None) -> int:
@@ -106,7 +152,11 @@ class MemoryDatabase:
return deleted return deleted
def search_memories(self, keywords: List[str], match_all: bool = False, limit: int = None) -> List[Dict]: def search_memories(self, keywords: List[str], match_all: bool = False, limit: int = None) -> List[Dict]:
"""多关键词模糊搜索记忆""" """多关键词模糊搜索记忆。
优先走 FTS5 全文检索;FTS 无结果时(trigram 对 <3 字符的中文短词
无法命中、旧表 unicode61 对中文不分词等场景)用 LIKE 兜底。
"""
if not keywords: if not keywords:
return [] return []
@@ -117,11 +167,21 @@ class MemoryDatabase:
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
cursor = conn.cursor() cursor = conn.cursor()
if match_all: results = self._search_via_fts(cursor, keywords, match_all, limit)
query = " AND ".join([f'"{kw}"' for kw in keywords]) if not results:
else: results = self._search_via_like(cursor, keywords, match_all, limit)
query = " OR ".join([f'"{kw}"' for kw in keywords])
return results
def _search_via_fts(self, cursor, keywords: List[str], match_all: bool, limit: int) -> List[Dict]:
"""FTS5 全文检索(关键词含特殊字符导致 MATCH 语法错误时返回空,走 LIKE 兜底)"""
joiner = " AND " if match_all else " OR "
# N7:短语内双引号翻倍转义——关键词含 " 时若不翻倍会提前闭合短语
# 边界,破坏 MATCH 语法或改变匹配语义(FTS5 短语引号转义规则)
terms = [f'"{kw.replace(chr(34), chr(34) * 2)}"' for kw in keywords]
query = joiner.join(terms)
try:
cursor.execute(''' cursor.execute('''
SELECT m.*, rank SELECT m.*, rank
FROM memories m FROM memories m
@@ -130,25 +190,57 @@ class MemoryDatabase:
ORDER BY m.end_time DESC, rank, m.importance DESC ORDER BY m.end_time DESC, rank, m.importance DESC
LIMIT ? LIMIT ?
''', (query, limit)) ''', (query, limit))
except sqlite3.OperationalError as e:
self.logger.warning(f"FTS5 检索失败(改用 LIKE 兜底): {e}")
return []
results = [] return self._rows_to_memories(cursor.fetchall())
for row in cursor.fetchall():
memory = dict(row)
if memory.get('tags'):
memory['tags'] = memory['tags'].split(',')
results.append(memory)
def _search_via_like(self, cursor, keywords: List[str], match_all: bool, limit: int) -> List[Dict]:
"""LIKE 模糊检索兜底(转义 %/_/\\ 防止关键词被当作通配符)"""
fields = ["topic", "summary", "content", "tags"]
def escape(kw: str) -> str:
return kw.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
conditions = []
params = []
for kw in keywords:
pattern = f"%{escape(kw)}%"
per_field = " OR ".join([f"{f} LIKE ? ESCAPE '\\'" for f in fields])
conditions.append(f"({per_field})")
params.extend([pattern] * len(fields))
joiner = " AND " if match_all else " OR "
cursor.execute(
f"SELECT * FROM memories WHERE {joiner.join(conditions)} "
f"ORDER BY end_time DESC, importance DESC LIMIT ?",
(*params, limit),
)
return self._rows_to_memories(cursor.fetchall())
@staticmethod
def _rows_to_memories(rows) -> List[Dict]:
"""sqlite3.Row 列表转记忆字典列表(tags 逗号串拆分为列表)"""
results = []
for row in rows:
memory = dict(row)
if memory.get('tags'):
memory['tags'] = memory['tags'].split(',')
results.append(memory)
return results return results
def get_recent_memories(self, limit: int = 10) -> List[Dict]: def get_recent_memories(self, limit: int = 10) -> List[Dict]:
"""获取最近的记忆。limit=None 时不限制返回条数(供分页使用)。""" """获取最近的记忆。limit=None 时使用默认上限(M4:此前 None 无 LIMIT
全表加载,记忆库增长后单次调用 fetchall 全量入内存;与 search_memories
的 None→默认上限语义对齐)。"""
if limit is None:
limit = constants.MEMORY_MAX_SEARCH_RESULTS
with sqlite3.connect(self.db_path) as conn: with sqlite3.connect(self.db_path) as conn:
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
cursor = conn.cursor() cursor = conn.cursor()
if limit is None: cursor.execute('SELECT * FROM memories ORDER BY end_time DESC LIMIT ?', (limit,))
cursor.execute('SELECT * FROM memories ORDER BY end_time DESC')
else:
cursor.execute('SELECT * FROM memories ORDER BY end_time DESC LIMIT ?', (limit,))
results = [] results = []
for row in cursor.fetchall(): for row in cursor.fetchall():
@@ -310,11 +402,14 @@ class MemoDatabase:
# 如果传入了 before_time,使用它,否则使用当前时间 # 如果传入了 before_time,使用它,否则使用当前时间
compare_time = before_time if before_time else now compare_time = before_time if before_time else now
# I 批(审计 2026-08-18):去掉 SQL 层 reminder_time <= ? 预过滤——
# 字符串比较会把格式异常的时间(如 "不是时间")静默排除在 SQL 层,
# Python 二次验证永远看不到它们(Q5:格式异常任务按到期返回)。
# 时间过滤改由下方 Python 解析做权威判断(本地小库全扫描可忽略)。
query = ''' query = '''
SELECT * FROM memos SELECT * FROM memos
WHERE is_completed = 0 WHERE is_completed = 0
AND reminder_time IS NOT NULL AND reminder_time IS NOT NULL
AND reminder_time <= ? -- 严格小于等于比较时间
AND (repeat_end_time IS NULL OR repeat_end_time >= ?) AND (repeat_end_time IS NULL OR repeat_end_time >= ?)
''' '''
@@ -323,10 +418,13 @@ class MemoDatabase:
query += ' ORDER BY reminder_time ASC, priority DESC' query += ' ORDER BY reminder_time ASC, priority DESC'
params = [compare_time, compare_time] params = [compare_time]
if limit: # I 批:limit=0 语义修正——此前 `if limit:` 使 0 等同"不限",
query += f' LIMIT {limit}' # 与 None=不限的惯例冲突;改为 None 才不加 LIMIT
if limit is not None:
query += ' LIMIT ?'
params.append(max(0, int(limit)))
cursor.execute(query, params) cursor.execute(query, params)
@@ -347,7 +445,11 @@ class MemoDatabase:
# 调试日志 # 调试日志
self.logger.debug(f"过滤掉未到期任务 #{memo['id']}: {memo['reminder_time']} > {compare_time}") self.logger.debug(f"过滤掉未到期任务 #{memo['id']}: {memo['reminder_time']} > {compare_time}")
except (ValueError, TypeError) as e: except (ValueError, TypeError) as e:
self.logger.warning(f"解析任务 #{memo['id']} 的提醒时间失败: {e}") # I 批(审计 2026-08-18):格式异常此前被静默丢弃——
# 任务永不执行且用户无感知。改为按"到期"返回并告警,
# 至少保证任务被处理而非永久丢失
self.logger.warning(f"解析任务 #{memo['id']} 的提醒时间失败,按到期处理: {e}")
results.append(memo)
else: else:
# 没有提醒时间的任务不应该在待执行列表中 # 没有提醒时间的任务不应该在待执行列表中
self.logger.warning(f"任务 #{memo['id']} 没有提醒时间,但被返回为待执行") self.logger.warning(f"任务 #{memo['id']} 没有提醒时间,但被返回为待执行")
@@ -367,10 +469,14 @@ class MemoDatabase:
ORDER BY created_at ASC ORDER BY created_at ASC
''' '''
if limit: params = []
query += f' LIMIT {limit}' # I 批:limit=0 语义修正——此前 `if limit:` 使 0 等同"不限",
# 与 None=不限的惯例冲突;改为 None 才不加 LIMIT
if limit is not None:
query += ' LIMIT ?'
params.append(max(0, int(limit)))
cursor.execute(query) cursor.execute(query, params)
results = [] results = []
for row in cursor.fetchall(): for row in cursor.fetchall():
@@ -420,64 +526,82 @@ class MemoDatabase:
def mark_as_triggered(self, memo_id: int) -> Optional[str]: def mark_as_triggered(self, memo_id: int) -> Optional[str]:
"""标记任务已被触发,并根据重复规则计算下一次提醒时间""" """标记任务已被触发,并根据重复规则计算下一次提醒时间"""
memo = self.get_memo(memo_id) # N10:读取与更新收进同一连接的 BEGIN IMMEDIATE 事务。此前先
if not memo: # get_memo(独立连接)后 UPDATE,check-then-act 之间存在竞态:
return None # 调度器并发触发同一任务会双计数/双推进 repeat。IMMEDIATE 在事务
# 开始即取写锁,串行化并发触发。
now = datetime.now() now = datetime.now()
next_reminder = None next_reminder = None
with sqlite3.connect(self.db_path) as conn: with sqlite3.connect(self.db_path) as conn:
conn.row_factory = sqlite3.Row
cursor = conn.cursor() cursor = conn.cursor()
# I 批(审计 2026-08-18):多进程同库(CLI --memo-add + 服务调度)
# 写竞争时默认 5s 后直接报 database is locked——busy_timeout 让
# SQLite 等待锁释放(最长 10s)而不是立即失败
cursor.execute("PRAGMA busy_timeout = 10000")
cursor.execute("BEGIN IMMEDIATE")
try:
cursor.execute('SELECT * FROM memos WHERE id = ?', (memo_id,))
row = cursor.fetchone()
if row is None:
conn.rollback()
return None
memo = dict(row)
if memo.get('tags'):
memo['tags'] = memo['tags'].split(',')
cursor.execute(''' cursor.execute('''
UPDATE memos UPDATE memos
SET last_triggered_at = ?, trigger_count = trigger_count + 1 SET last_triggered_at = ?, trigger_count = trigger_count + 1
WHERE id = ? WHERE id = ?
''', (now.strftime(constants.DATETIME_FORMAT), memo_id)) ''', (now.strftime(constants.DATETIME_FORMAT), memo_id))
# 处理重复任务 # 处理重复任务
if memo['repeat_type'] != 'none' and not self._should_end_repeat(memo, now): if memo['repeat_type'] != 'none' and not self._should_end_repeat(memo, now):
# 使用当前时间作为基准来计算下一次执行时间 # 使用当前时间作为基准来计算下一次执行时间
next_reminder = self._calculate_next_reminder(memo, now) next_reminder = self._calculate_next_reminder(memo, now)
if next_reminder: if next_reminder:
cursor.execute(''' cursor.execute('''
UPDATE memos UPDATE memos
SET reminder_time = ? SET reminder_time = ?
WHERE id = ? WHERE id = ?
''', (next_reminder.strftime(constants.DATETIME_FORMAT), memo_id)) ''', (next_reminder.strftime(constants.DATETIME_FORMAT), memo_id))
self.logger.info(f"任务 #{memo_id} 已更新下一次执行时间: {next_reminder}") self.logger.info(f"任务 #{memo_id} 已更新下一次执行时间: {next_reminder}")
else: else:
# 重复结束,标记为完成 # 重复结束,标记为完成
cursor.execute('''
UPDATE memos
SET is_completed = 1, completed_at = ?
WHERE id = ?
''', (now.strftime(constants.DATETIME_FORMAT), memo_id))
self.logger.info(f"任务 #{memo_id} 重复结束,已标记为完成")
elif memo['is_immediate']:
# 即时任务执行后标记为完成
cursor.execute(''' cursor.execute('''
UPDATE memos UPDATE memos
SET is_completed = 1, completed_at = ? SET is_completed = 1, completed_at = ?
WHERE id = ? WHERE id = ?
''', (now.strftime(constants.DATETIME_FORMAT), memo_id)) ''', (now.strftime(constants.DATETIME_FORMAT), memo_id))
self.logger.info(f"任务 #{memo_id} 重复结束,已标记为完成") self.logger.info(f"即时任务 #{memo_id} 已完成")
elif memo['is_immediate']: else:
# 即时任务执行后标记为完成 # 一次性任务,标记为完成
cursor.execute(''' cursor.execute('''
UPDATE memos UPDATE memos
SET is_completed = 1, completed_at = ? SET is_completed = 1, completed_at = ?
WHERE id = ? WHERE id = ?
''', (now.strftime(constants.DATETIME_FORMAT), memo_id)) ''', (now.strftime(constants.DATETIME_FORMAT), memo_id))
self.logger.info(f"即时任务 #{memo_id} 已完成") self.logger.info(f"任务 #{memo_id} 已完成")
else:
# 一次性任务,标记为完成
cursor.execute('''
UPDATE memos
SET is_completed = 1, completed_at = ?
WHERE id = ?
''', (now.strftime(constants.DATETIME_FORMAT), memo_id))
self.logger.info(f"任务 #{memo_id} 已完成") conn.commit()
except Exception:
conn.commit() conn.rollback()
raise
return next_reminder.strftime(constants.DATETIME_FORMAT) if next_reminder else None return next_reminder.strftime(constants.DATETIME_FORMAT) if next_reminder else None
@@ -564,6 +688,15 @@ class MemoDatabase:
return True return True
return False return False
def delete_memo(self, memo_id: int) -> bool:
"""物理删除任务(M5:delete 语义此前被 complete_memo 顶替——只标记
完成不删行,重复任务还会被推进而非移除,stats 计数失真)"""
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM memos WHERE id = ?", (memo_id,))
conn.commit()
return cursor.rowcount > 0
def get_memo_stats(self) -> Dict: def get_memo_stats(self) -> Dict:
"""获取任务统计信息""" """获取任务统计信息"""
with sqlite3.connect(self.db_path) as conn: with sqlite3.connect(self.db_path) as conn:
+148 -84
View File
@@ -13,6 +13,7 @@ subsystems.executors - 命令执行器
import os import os
import queue import queue
import signal
import subprocess import subprocess
import threading import threading
import time import time
@@ -71,62 +72,148 @@ class SubprocessCommandExecutor(CommandExecutor):
def __init__(self, logger=None): def __init__(self, logger=None):
super().__init__(logger) super().__init__(logger)
self._current_process = None self._current_process = None
self._current_pgid = None # L9:Popen 后立即捕获,shell 死亡后 getpgid 会失败
self._process_lock = threading.Lock() self._process_lock = threading.Lock()
def _terminate_tree(self, proc, force: bool = False):
"""终止整个进程组(L9:POSIX 下 shell=True 只杀 shell 时,孙进程
(`(x &)` 等)仍持管道写端——reader 阻塞无 EOF、fd/线程悬挂至孙
进程退出,写满 64KB 管道缓冲后孙进程也卡死。start_new_session 使
shell 成为会话首进程,按组信号可一次覆盖整棵进程树)。
组 ID 在 Popen 后立即保存:shell 死亡后 os.getpgid(pid) 会抛
ProcessLookupError,届时无法再从进程反查组。
Windows 无进程组概念,退回单进程 terminate/kill。"""
try:
if os.name == "posix":
sig = signal.SIGKILL if force else signal.SIGTERM
with self._process_lock:
pgid = self._current_pgid
if pgid is None:
pgid = os.getpgid(proc.pid)
os.killpg(pgid, sig)
elif force:
proc.kill()
else:
proc.terminate()
except Exception:
try:
proc.kill()
except Exception:
pass
def cancel(self): def cancel(self):
"""取消当前正在运行的命令(kill子进程)。线程安全。""" """取消当前正在运行的命令(kill子进程组)。线程安全。"""
with self._process_lock: with self._process_lock:
proc = self._current_process proc = self._current_process
if proc is not None: if proc is not None:
try: try:
if proc.poll() is None: if proc.poll() is None:
proc.kill() self._terminate_tree(proc, force=True)
self.logger.info("已终止正在运行的子进程") self.logger.info("已终止正在运行的子进程组")
except Exception: except Exception:
pass pass
def _read_with_limit(self, process, timeout: int, start_time: float, output_queue=None) -> Tuple[str, bool, bool]: def _read_with_limit(self, process, timeout: int, start_time: float, output_queue=None) -> Tuple[str, bool, bool]:
"""从进程读取输出,限制总大小和超时。 """从进程读取输出,限制总大小和超时。返回 (output_str, timeout_occurred, truncated)。
返回 (output_str, timeout_occurred, truncated)
H2 修复:阻塞 readline 在独立 reader 线程执行,主循环带超时轮询队列。
此前超时检查只在两次 readline 之间执行,无输出命令会让 readline
阻塞到进程自然退出,timeout 形同虚设(审计 PoC 实证)。
""" """
output_chunks = [] output_chunks = []
total_bytes = 0 total_bytes = 0
timeout_occurred = False timeout_occurred = False
truncated = False truncated = False
line_queue: queue.Queue = queue.Queue()
reader_done = threading.Event()
def _reader():
"""后台 reader 线程:阻塞 readline,行入队;EOF/异常结束"""
try:
for line in iter(process.stdout.readline, ""):
line_queue.put(line)
except Exception:
pass
finally:
reader_done.set()
reader = threading.Thread(target=_reader, daemon=True)
reader.start()
shell_dead_since = None # shell 进程已死但 reader 未收到 EOF 的起始时刻
while True: while True:
# 检查超时 # 检查超时(readline 阻塞不再影响本判断)
if timeout > 0 and time.time() - start_time > timeout: if timeout > 0 and time.time() - start_time > timeout:
process.terminate() self._terminate_tree(process)
timeout_occurred = True timeout_occurred = True
break break
# 检查输出大小 # 检查输出大小
if total_bytes >= self.MAX_OUTPUT_BYTES: if total_bytes >= self.MAX_OUTPUT_BYTES:
truncated = True truncated = True
process.terminate() self._terminate_tree(process)
break break
# 读取一行 try:
line = process.stdout.readline() line = line_queue.get(timeout=0.1)
if not line and process.poll() is not None: shell_dead_since = None
break except queue.Empty:
if line: if reader_done.is_set():
line_bytes = len(line.encode('utf-8', errors='replace'))
if total_bytes + line_bytes > self.MAX_OUTPUT_BYTES and not truncated:
allowed = self.MAX_OUTPUT_BYTES - total_bytes
if allowed > 0:
output_chunks.append(line.encode('utf-8', errors='replace')[:allowed].decode('utf-8', errors='replace'))
total_bytes = self.MAX_OUTPUT_BYTES
truncated = True
process.terminate()
break break
# shell 已被 kill/退出但 reader 仍阻塞(如 Windows shell=True
# 下 cancel 只杀死包装进程,孙进程仍持有管道写端导致无 EOF):
# 宽限 1 秒排空残留输出后放弃等待
if process.poll() is not None:
if shell_dead_since is None:
shell_dead_since = time.time()
elif time.time() - shell_dead_since > 1.0:
# L9:shell 已死但孙进程仍持管道写端(`(sleep 300 &) ;
# echo done` 形态)。只靠 daemon reader 兜底会悬挂线程
# 与管道 fd 至孙进程退出,写满 64KB 缓冲后孙进程自身
# 也卡死。此处强杀进程组收尾(nohup 重定向输出的
# 真正脱离场景不持管道,不会走到这里)。
self._terminate_tree(process, force=True)
break
continue
output_chunks.append(line) line_bytes = len(line.encode('utf-8', errors='replace'))
total_bytes += line_bytes if total_bytes + line_bytes > self.MAX_OUTPUT_BYTES and not truncated:
allowed = self.MAX_OUTPUT_BYTES - total_bytes
if allowed > 0:
# L5:decode 用 errors='ignore'——按字节截断可能落在多字节
# 字符中间,errors='replace' 会在行尾留下半个字符(�)
output_chunks.append(line.encode('utf-8', errors='replace')[:allowed].decode('utf-8', errors='ignore'))
total_bytes = self.MAX_OUTPUT_BYTES
truncated = True
self._terminate_tree(process)
break
if output_queue: output_chunks.append(line)
output_queue.put(("line", line.rstrip('\n') + "\n")) total_bytes += line_bytes
if output_queue:
output_queue.put(("line", line.rstrip('\n') + "\n"))
# N4 修复(审计 2026-08-18,二次修正):reader 线程收尾。
# 超时/截断/宽限到期 break 后,reader 可能仍阻塞在 readline
# (Windows shell=True 下孙进程持管道写端,永远等不到 EOF)。
# 注意:此时不能 close stdout——CPython 流对象内部有锁,
# readline 持锁阻塞时 close() 会一同挂起直到 readline 返回
# (实测命令超时被拖满整个 Start-Sleep 时长)。reader 为 daemon
# 线程,孙进程退出收到 EOF 后自然结束,不阻塞主流程;
# reader 已正常退出的场景才 close 显式释放管道 fd。
if reader_done.is_set():
try:
process.stdout.close()
except Exception:
pass
else:
reader.join(timeout=2)
if reader.is_alive():
self.logger.warning(
"reader 线程仍阻塞在管道读取(孙进程持写端),已放弃等待,"
"线程由 daemon 兜底、随管道 EOF 自然退出")
return ''.join(output_chunks), timeout_occurred, truncated return ''.join(output_chunks), timeout_occurred, truncated
@@ -153,30 +240,35 @@ class SubprocessCommandExecutor(CommandExecutor):
shell=True, shell=True,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, stderr=subprocess.STDOUT,
stdin=subprocess.PIPE, stdin=subprocess.DEVNULL, # H3 修复:不挂 stdin,防等待输入命令永久挂起
text=True, text=True,
bufsize=1, bufsize=1,
universal_newlines=True, universal_newlines=True,
env=exec_env, env=exec_env,
cwd=os.path.expanduser(work_dir) cwd=os.path.expanduser(work_dir),
# L9:POSIX 下 shell 成为会话首进程,超时/取消可按进程组
# 一次终止整棵进程树(含 & 后台孙进程),消除管道悬挂
start_new_session=(os.name == "posix")
) )
# 保存进程引用以便cancel()可以kill它 # 保存进程引用以便cancel()可以kill它
with self._process_lock: with self._process_lock:
self._current_process = process self._current_process = process
if os.name == "posix":
try:
self._current_pgid = os.getpgid(process.pid)
except Exception:
self._current_pgid = None
start_time = time.time() start_time = time.time()
output_str, timeout_occurred, truncated = self._read_with_limit(process, timeout, start_time) output_str, timeout_occurred, truncated = self._read_with_limit(process, timeout, start_time)
# 确保进程终止 # 确保进程终止
if process.poll() is None: if process.poll() is None:
if timeout_occurred or truncated: self._terminate_tree(process, force=timeout_occurred or truncated)
process.kill()
else:
process.terminate()
try: try:
process.wait(timeout=2) process.wait(timeout=2)
except: except:
process.kill() self._terminate_tree(process, force=True)
return_code = process.returncode if process.returncode is not None else -1 return_code = process.returncode if process.returncode is not None else -1
@@ -204,10 +296,11 @@ class SubprocessCommandExecutor(CommandExecutor):
return f"❌ 执行错误: {str(e)}" return f"❌ 执行错误: {str(e)}"
finally: finally:
if process and process.poll() is None: if process and process.poll() is None:
process.kill() self._terminate_tree(process, force=True)
# 清除当前进程引用 # 清除当前进程引用
with self._process_lock: with self._process_lock:
self._current_process = None self._current_process = None
self._current_pgid = None
def execute_streaming(self, command: str, output_queue: queue.Queue, timeout: int = 30, def execute_streaming(self, command: str, output_queue: queue.Queue, timeout: int = 30,
work_dir: str = None, env: Dict = None, **kwargs): work_dir: str = None, env: Dict = None, **kwargs):
@@ -240,67 +333,39 @@ class SubprocessCommandExecutor(CommandExecutor):
shell=True, shell=True,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, stderr=subprocess.STDOUT,
stdin=subprocess.PIPE, stdin=subprocess.DEVNULL, # H3 修复:不挂 stdin,防等待输入命令永久挂起
text=True, text=True,
bufsize=1, bufsize=1,
universal_newlines=True, universal_newlines=True,
env=exec_env, env=exec_env,
cwd=os.path.expanduser(work_dir) cwd=os.path.expanduser(work_dir),
# L9:POSIX 下 shell 成为会话首进程,超时/取消可按进程组
# 一次终止整棵进程树(含 & 后台孙进程),消除管道悬挂
start_new_session=(os.name == "posix")
) )
self.process = process self.process = process
# 保存进程引用以便cancel()可以kill它 # 保存进程引用以便cancel()可以kill它
with self._process_lock: with self._process_lock:
self._current_process = process self._current_process = process
if os.name == "posix":
try:
self._current_pgid = os.getpgid(process.pid)
except Exception:
self._current_pgid = None
start_time = time.time() start_time = time.time()
output_lines = [] # H2 修复:复用线程化读取(reader 线程 + 超时轮询),行经 output_queue 流式转发
total_bytes = 0 output_str, timeout_occurred, truncated = self._read_with_limit(
timeout_occurred = False process, timeout, start_time, output_queue=output_queue)
truncated = False
# 实时读取输出 if timeout_occurred:
while True: output_queue.put(("line", f"\n⏰ 命令执行超时({timeout}秒)\n"))
# 检查超时 elif truncated:
if timeout > 0 and time.time() - start_time > timeout: output_queue.put(("line", f"\n📦 输出超过 {self.MAX_OUTPUT_BYTES/1024/1024:.0f}MB 限制,结果已截断\n"))
process.terminate()
timeout_occurred = True
output_queue.put(("line", f"\n⏰ 命令执行超时({timeout}秒)\n"))
break
# 检查输出大小
if total_bytes >= self.MAX_OUTPUT_BYTES:
truncated = True
process.terminate()
output_queue.put(("line", f"\n📦 输出超过 {self.MAX_OUTPUT_BYTES/1024/1024:.0f}MB 限制,结果已截断\n"))
break
# 读取一行输出
line = process.stdout.readline()
if not line and process.poll() is not None:
break
if line:
line_bytes = len(line.encode('utf-8', errors='replace'))
if total_bytes + line_bytes > self.MAX_OUTPUT_BYTES and not truncated:
allowed = self.MAX_OUTPUT_BYTES - total_bytes
if allowed > 0:
truncated_line = line.encode('utf-8', errors='replace')[:allowed].decode('utf-8', errors='replace')
output_queue.put(("line", truncated_line))
output_lines.append(truncated_line)
total_bytes = self.MAX_OUTPUT_BYTES
truncated = True
process.terminate()
output_queue.put(("line", f"\n📦 输出超过 {self.MAX_OUTPUT_BYTES/1024/1024:.0f}MB 限制,结果已截断\n"))
break
stripped = line.rstrip('\n')
output_queue.put(("line", stripped + "\n"))
output_lines.append(line)
total_bytes += line_bytes
# 如果超时或截断,确保进程被终止 # 如果超时或截断,确保进程被终止
if (timeout_occurred or truncated) and process.poll() is None: if (timeout_occurred or truncated) and process.poll() is None:
process.kill() self._terminate_tree(process, force=True)
try: try:
process.wait(timeout=2) process.wait(timeout=2)
except: except:
@@ -317,10 +382,8 @@ class SubprocessCommandExecutor(CommandExecutor):
else: else:
output_queue.put(("line", f"✅ 命令执行完成,返回码: {return_code}\n")) output_queue.put(("line", f"✅ 命令执行完成,返回码: {return_code}\n"))
# 准备完整结果
full_output = "".join(output_lines)
data = { data = {
"输出": full_output.strip() if full_output.strip() else "(无输出)", "输出": output_str.strip() if output_str.strip() else "(无输出)",
"返回码": return_code "返回码": return_code
} }
@@ -339,11 +402,12 @@ class SubprocessCommandExecutor(CommandExecutor):
output_queue.put(("complete", f"❌ 执行错误: {str(e)}")) output_queue.put(("complete", f"❌ 执行错误: {str(e)}"))
finally: finally:
if process and process.poll() is None: if process and process.poll() is None:
process.terminate() self._terminate_tree(process)
try: try:
process.wait(timeout=2) process.wait(timeout=2)
except: except:
process.kill() self._terminate_tree(process, force=True)
self.process = None self.process = None
with self._process_lock: with self._process_lock:
self._current_process = None self._current_process = None
self._current_pgid = None
+33 -16
View File
@@ -18,6 +18,7 @@ subsystems.prompt_manager - 提示词管理器
import os import os
import re import re
import shutil import shutil
import threading
from datetime import datetime from datetime import datetime
from typing import Dict, List, Optional from typing import Dict, List, Optional
@@ -35,6 +36,7 @@ class PromptManager:
""" """
_instance = None _instance = None
_instance_lock = threading.Lock() # I 批:并发首例化竞态防护
_prompts = [] _prompts = []
_prompts_dir = None _prompts_dir = None
@@ -43,12 +45,18 @@ class PromptManager:
STATUS_FILE = "100_STATUS.md" STATUS_FILE = "100_STATUS.md"
SOUL_FILE = "99_SOUL.md" # 智能体灵魂文件 - 所有角色定位内容 SOUL_FILE = "99_SOUL.md" # 智能体灵魂文件 - 所有角色定位内容
# N9(审计 2026-08-18):Agent 可控内容直写 00/99/100 文件并作为后续
# 会话系统提示词加载(持久化注入面)。内容长度上限约束注入规模,
# 超长内容拒绝写入(工具层有更友好的提示)。
MAX_CONTENT_LENGTH = 10000
# 基础技能文件名模式:N_xxx.md(N 为数字,如 1_memory_search.md、10_task_handling.md) # 基础技能文件名模式:N_xxx.md(N 为数字,如 1_memory_search.md、10_task_handling.md)
_SKILL_FILE_RE = re.compile(r'^(\d+)_') _SKILL_FILE_RE = re.compile(r'^(\d+)_')
def __new__(cls): def __new__(cls):
if cls._instance is None: with cls._instance_lock:
cls._instance = super().__new__(cls) if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance return cls._instance
def __init__(self): def __init__(self):
@@ -60,10 +68,16 @@ class PromptManager:
prompts_dir = config.get("system.prompts_dir", "~/.aibox/prompts") prompts_dir = config.get("system.prompts_dir", "~/.aibox/prompts")
return os.path.expanduser(prompts_dir) return os.path.expanduser(prompts_dir)
def _load_prompts(self): def _load_prompts(self, target: Optional[List[Dict]] = None):
"""加载提示词到 target 列表(缺省 self._prompts)。
M6:reload 传入新列表本地构建,完成后由 reload 原子替换——
此前先清空 self._prompts 再逐文件重读,并发读者(其他连接构建
system 消息)会读到空/半截提示词列表。
"""
prompts = target if target is not None else self._prompts
self._prompts_dir = self._get_prompts_dir() self._prompts_dir = self._get_prompts_dir()
os.makedirs(self._prompts_dir, exist_ok=True) os.makedirs(self._prompts_dir, exist_ok=True)
self._prompts = []
# 确保核心文件存在 # 确保核心文件存在
self._ensure_identity_file() self._ensure_identity_file()
@@ -86,17 +100,14 @@ class PromptManager:
with open(filepath, 'r', encoding='utf-8') as f: with open(filepath, 'r', encoding='utf-8') as f:
content = f.read().strip() content = f.read().strip()
if content: if content:
self._prompts.append({ prompts.append({
'file': filename, 'file': filename,
'content': content 'content': content
}) })
except Exception as e: except Exception as e:
print(f"⚠️ 读取提示词文件 {filename} 失败: {e}") print(f"⚠️ 读取提示词文件 {filename} 失败: {e}")
essential_files = [self.IDENTITY_FILE, self.STATUS_FILE, self.SOUL_FILE] if not prompts:
other_files = [f for f in prompt_files if f not in essential_files] if prompt_files else []
if not self._prompts:
self._create_default_prompts() self._create_default_prompts()
def _get_template_dir(self) -> str: def _get_template_dir(self) -> str:
@@ -148,6 +159,9 @@ class PromptManager:
def update_identity(self, content: str) -> bool: def update_identity(self, content: str) -> bool:
"""更新用户身份信息""" """更新用户身份信息"""
if len(content or "") > self.MAX_CONTENT_LENGTH:
print(f"⚠️ 身份内容超长({len(content)} > {self.MAX_CONTENT_LENGTH} 字符),拒绝写入")
return False
identity_path = os.path.join(self._prompts_dir, self.IDENTITY_FILE) identity_path = os.path.join(self._prompts_dir, self.IDENTITY_FILE)
try: try:
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
@@ -168,6 +182,9 @@ class PromptManager:
def update_status(self, content: str) -> bool: def update_status(self, content: str) -> bool:
"""更新智能体状态""" """更新智能体状态"""
if len(content or "") > self.MAX_CONTENT_LENGTH:
print(f"⚠️ 状态内容超长({len(content)} > {self.MAX_CONTENT_LENGTH} 字符),拒绝写入")
return False
status_path = os.path.join(self._prompts_dir, self.STATUS_FILE) status_path = os.path.join(self._prompts_dir, self.STATUS_FILE)
try: try:
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
@@ -188,6 +205,9 @@ class PromptManager:
def update_soul(self, content: str) -> bool: def update_soul(self, content: str) -> bool:
"""更新智能体灵魂定义 - 所有角色定位内容""" """更新智能体灵魂定义 - 所有角色定位内容"""
if len(content or "") > self.MAX_CONTENT_LENGTH:
print(f"⚠️ 灵魂内容超长({len(content)} > {self.MAX_CONTENT_LENGTH} 字符),拒绝写入")
return False
soul_path = os.path.join(self._prompts_dir, self.SOUL_FILE) soul_path = os.path.join(self._prompts_dir, self.SOUL_FILE)
try: try:
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
@@ -240,10 +260,6 @@ class PromptManager:
return """我是DeepSeek,一个智能AI助手。 return """我是DeepSeek,一个智能AI助手。
请创建 99_SOUL.md 文件来定义我的完整身份、角色、性格和能力边界。""" 请创建 99_SOUL.md 文件来定义我的完整身份、角色、性格和能力边界。"""
def get_skill_prompts(self) -> List[Dict]:
"""获取所有基础技能提示词(以1_开头的文件)"""
return [p for p in self._prompts if p['file'].startswith(self.SKILL_PREFIX)]
def get_soul_prompt(self) -> Optional[Dict]: def get_soul_prompt(self) -> Optional[Dict]:
"""获取灵魂提示词(99_SOUL.md)""" """获取灵魂提示词(99_SOUL.md)"""
for p in self._prompts: for p in self._prompts:
@@ -296,9 +312,10 @@ class PromptManager:
return [p for p in self._prompts if self._skill_sort_key(p['file']) is not None] return [p for p in self._prompts if self._skill_sort_key(p['file']) is not None]
def reload(self): def reload(self):
"""重新加载所有提示词""" """重新加载所有提示词(M6:构建新列表后原子替换,并发读者始终读到完整列表)"""
self._prompts = [] new_prompts: List[Dict] = []
self._load_prompts() self._load_prompts(new_prompts)
self._prompts = new_prompts
return len(self._prompts) return len(self._prompts)
def get_prompt_count(self) -> int: def get_prompt_count(self) -> int:
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""审计修复 PoC 回归验证(H1/H2/H3 + 1MB + cancel)。
验证 2026-08-17 审计五项修复的实际运行效果:
PoC-1 (H2): 无输出命令 + timeout=3s → 应 ~3s 返回超时(修复前阻塞 15s+)
PoC-2 (H3): 等待 stdin 的命令 + timeout=3s → 应超时返回而非永久挂起
PoC-3 : 输出 >1MB → 应截断且总输出 ≈1MB
PoC-4 : cancel() 应在宽限时间内终止长命令
PoC-5 (H1) : CommandLineTool(工具层,非 executor)跑 >1MB 输出 →
截断生效即证明已走 executor 委托路径
"""
import queue
import sys
import threading
import time
import types
from pathlib import Path
AIBOX_DIR = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(AIBOX_DIR))
sys.modules.setdefault("websockets", types.ModuleType("websockets"))
from subsystems.executors import SubprocessCommandExecutor
from tools.command_tool import CommandLineTool
results = []
def check(name, ok, detail):
results.append((name, ok, detail))
print(f"[{'PASS' if ok else 'FAIL'}] {name}: {detail}")
# ---------- PoC-1 (H2): 无输出命令超时 ----------
ex = SubprocessCommandExecutor()
t0 = time.time()
r = ex.execute("powershell -NoProfile -Command Start-Sleep 15", timeout=3)
elapsed = time.time() - t0
check("PoC-1 无输出命令超时(H2)", elapsed < 6 and "超时" in r,
f"{elapsed:.1f}s 返回(修复前阻塞15s+),结果含超时: {'超时' in r}")
# ---------- PoC-2 (H3): 等待 stdin 的命令 ----------
# stdin=DEVNULL 下命令立即收到 EOF:要么快速退出,要么按 timeout 返回;
# 判定标准 = 不永久挂起(修复前 readline 无 EOF 永久阻塞)。
t0 = time.time()
r = ex.execute("powershell -NoProfile -Command Read-Host", timeout=3)
elapsed = time.time() - t0
check("PoC-2 stdin等待命令不挂起(H3)", elapsed < 6,
f"{elapsed:.1f}s 返回(修复前永久挂起)")
# ---------- PoC-3: 1MB 输出截断 ----------
t0 = time.time()
r = ex.execute(
'powershell -NoProfile -Command "$s=\'x\'*2048; 1..1024 | ForEach-Object { $s }"',
timeout=60)
elapsed = time.time() - t0
size_mb = len(r.encode("utf-8", errors="replace")) / 1024 / 1024
check("PoC-3 1MB输出截断", "截断" in r and size_mb < 1.15,
f"{elapsed:.1f}s,结果 {size_mb:.2f}MB,含截断标记: {'截断' in r}")
# ---------- PoC-4: cancel() 中断 ----------
out_q = queue.Queue()
done = threading.Event()
def _run():
ex.execute_streaming(
"powershell -NoProfile -Command Start-Sleep 60",
out_q, timeout=120)
done.set()
t0 = time.time()
th = threading.Thread(target=_run, daemon=True)
th.start()
time.sleep(2)
ex.cancel()
done.wait(timeout=10)
elapsed = time.time() - t0
check("PoC-4 cancel中断", done.is_set() and elapsed < 8,
f"cancel 后 {elapsed:.1f}s 结束(修复前杀不到进程需等满120s)")
# ---------- PoC-5 (H1): 工具层委托 executor(截断保护贯通) ----------
tool = CommandLineTool(executor=ex)
t0 = time.time()
r = tool.execute(
command='powershell -NoProfile -Command "$s=\'y\'*2048; 1..1024 | ForEach-Object { $s }"',
timeout=60)
elapsed = time.time() - t0
size_mb = len(r.encode("utf-8", errors="replace")) / 1024 / 1024
check("PoC-5 工具层1MB贯通(H1)", "截断" in r and size_mb < 1.15,
f"{elapsed:.1f}s,工具层结果 {size_mb:.2f}MB(修复前无限累积)")
# ---------- 汇总 ----------
fails = [n for n, ok, _ in results if not ok]
print("\n==== 汇总: %d/%d PASS ====" % (len(results) - len(fails), len(results)))
sys.exit(1 if fails else 0)
+2
View File
@@ -13,6 +13,8 @@ from pathlib import Path
# 把 aibox 目录加入 sys.path(无需安装为包即可 import tools/common) # 把 aibox 目录加入 sys.path(无需安装为包即可 import tools/common)
AIBOX_DIR = Path(__file__).resolve().parent.parent AIBOX_DIR = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(AIBOX_DIR)) sys.path.insert(0, str(AIBOX_DIR))
# tests 目录也加入:测试文件互相导入(如 test_models_profile 复用 test_once_mode 的 _make_ai)
sys.path.insert(0, str(Path(__file__).resolve().parent))
# 桩掉 websockets(aibox.py 顶部 import websockets,测试环境可能未安装) # 桩掉 websockets(aibox.py 顶部 import websockets,测试环境可能未安装)
if "websockets" not in sys.modules: if "websockets" not in sys.modules:
+155 -1
View File
@@ -15,7 +15,7 @@ from datetime import datetime, timedelta
import pytest import pytest
from common import constants from common import ConfigManager, constants
from subsystems import ( from subsystems import (
MemoryDatabase, MemoDatabase, PromptManager, SkillManager, MemoryDatabase, MemoDatabase, PromptManager, SkillManager,
SubprocessCommandExecutor, SubprocessCommandExecutor,
@@ -425,6 +425,160 @@ def test_get_proxy_when_disabled():
assert "未启用" in r or "启用" in r assert "未启用" in r or "启用" in r
# ---------- SetProxyTool 连通性验证(设置前验证,失败不落盘) ----------
@pytest.fixture
def fresh_config(tmp_path, monkeypatch):
"""隔离 ConfigManager 单例:AI_CONFIG_PATH 指向临时文件,前后重置类状态"""
cfg_file = tmp_path / "config.json"
monkeypatch.setenv("AI_CONFIG_PATH", str(cfg_file))
def _reset():
ConfigManager._instance = None
ConfigManager._config = None
ConfigManager._config_path = None
_reset()
yield ConfigManager
_reset()
class _FakeResp:
def __init__(self, status_code):
self.status_code = status_code
def _fake_session_factory(exc=None, resp=None):
"""构造替代 requests.Session 的假类:get() 抛异常或返回假响应"""
calls = []
class _FakeSession:
def __init__(self):
self.trust_env = True
def get(self, *args, **kwargs):
calls.append((args, kwargs))
if exc:
raise exc
return resp
_FakeSession.calls = calls
return _FakeSession
def test_check_connectivity_ok(monkeypatch):
fake = _fake_session_factory(resp=_FakeResp(204))
monkeypatch.setattr("tools.proxy_tools.requests.Session", fake)
t = SetProxyTool()
ok, kind, detail = t._check_connectivity("127.0.0.1", 1080, None, None)
assert ok and kind == ""
# trust_env 必须关掉:排除系统 http_proxy 环境变量干扰
assert fake.calls, "应发出探测请求"
def test_check_connectivity_classification(monkeypatch, tmp_path):
"""异常类型/消息 → 错误类型分类(与系统语言无关)"""
import requests as requests_lib
cases = [
# Windows 本地化的连接拒绝消息:靠异常类型而非英文关键词分类
(requests_lib.exceptions.ConnectionError(
"[WinError 10061] 由于目标计算机积极拒绝,无法连接。"), "connect_failed"),
# ConnectTimeout 同时继承 ConnectionError/Timeout,必须按 Timeout 归类
(requests_lib.exceptions.ConnectTimeout("connection timeout"), "timeout"),
(requests_lib.exceptions.ConnectionError(
"SOCKS5 authentication failed"), "auth_failed"),
(requests_lib.exceptions.ConnectionError(
"Missing dependencies for SOCKS support."), "missing_dependency"),
(Exception("Read timed out"), "timeout"), # 兜底:非 requests 异常按消息
(Exception("Connection refused"), "connect_failed"),
(Exception("something weird"), "error"),
]
t = SetProxyTool()
for exc, expect_kind in cases:
monkeypatch.setattr(
"tools.proxy_tools.requests.Session",
_fake_session_factory(exc=exc))
ok, kind, _ = t._check_connectivity("127.0.0.1", 1080, "u", "p")
assert not ok
assert kind == expect_kind, f"{exc!r} 应分类为 {expect_kind},实际 {kind}"
def test_check_connectivity_bad_status(monkeypatch):
"""探测端点返回非 200/204 → probe_failed"""
monkeypatch.setattr(
"tools.proxy_tools.requests.Session",
_fake_session_factory(resp=_FakeResp(502)))
t = SetProxyTool()
ok, kind, detail = t._check_connectivity("127.0.0.1", 1080, None, None)
assert not ok and kind == "probe_failed"
def test_set_proxy_rejected_when_unreachable(fresh_config, monkeypatch):
"""验证失败:返回 ❌ 且配置不落盘"""
monkeypatch.setattr(
SetProxyTool, "_check_connectivity",
lambda self, h, p, u, pw: (False, "connect_failed", "Connection refused"))
t = SetProxyTool()
r = t.execute(host="10.255.255.1", port=1080)
assert "❌" in r
assert "未保存" in r
# 配置未写入:代理仍未启用
assert fresh_config().get_proxy() is None
def test_set_proxy_saved_when_check_passes(fresh_config, monkeypatch):
"""验证通过:配置保存且结果带连通性详情"""
monkeypatch.setattr(
SetProxyTool, "_check_connectivity",
lambda self, h, p, u, pw: (True, "", "探测成功 HTTP 204"))
t = SetProxyTool()
r = t.execute(host="127.0.0.1", port=1080)
assert "✅" in r
assert "探测成功" in r
proxies = fresh_config().get_proxy()
assert proxies and "127.0.0.1:1080" in proxies["http"]
def test_set_proxy_skip_check_bypasses_validation(fresh_config, monkeypatch):
"""skip_check=True:不验证直接保存(代理未启动的预配置场景)"""
called = []
def _no_call(self, *a, **kw):
called.append(1)
raise AssertionError("skip_check=True 时不应调用验证")
monkeypatch.setattr(SetProxyTool, "_check_connectivity", _no_call)
t = SetProxyTool()
r = t.execute(host="127.0.0.1", port=1080, skip_check=True)
assert "✅" in r
assert not called
assert fresh_config().get_proxy() is not None
def test_set_proxy_disabled_skips_check(fresh_config, monkeypatch):
"""enabled=False:禁用代理无需验证"""
def _no_call(self, *a, **kw):
raise AssertionError("禁用代理不应触发验证")
monkeypatch.setattr(SetProxyTool, "_check_connectivity", _no_call)
t = SetProxyTool()
r = t.execute(host="127.0.0.1", port=1080, enabled=False)
assert "✅" in r
assert fresh_config().get_proxy() is None # 禁用态 get_proxy 返回 None
def test_set_proxy_empty_env_hint(fresh_config, monkeypatch):
"""username + password_env 指向未设环境变量:精准提示而非裸 auth 失败"""
monkeypatch.delenv("AIBOX_TEST_SOCKS_PW", raising=False)
t = SetProxyTool()
r = t.execute(host="127.0.0.1", port=1080,
username="u1", password_env="AIBOX_TEST_SOCKS_PW")
assert "❌" in r
assert "环境变量" in r
assert "未保存" in r
assert fresh_config().get_proxy() is None
# ==================== AskUserTool ==================== # ==================== AskUserTool ====================
def test_ask_user_interface(): def test_ask_user_interface():
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""F1(N1 高危)修复回归测试:代理密码不落盘。
覆盖三点:
1. set_proxy(password=..., save=True) 后配置文件无明文密码(_save_config 脱敏)
2. password_env 解析:get_proxy() 从环境变量取密码拼 socks5:// URL
3. 旧版明文配置迁移:加载后盘上明文被清除,内存中本会话仍可用
"""
import json
import pytest
from common.config import ConfigManager
@pytest.fixture
def fresh_config(tmp_path, monkeypatch):
"""隔离 ConfigManager 单例:AI_CONFIG_PATH 指向临时文件,前后重置类状态"""
cfg_file = tmp_path / "config.json"
monkeypatch.setenv("AI_CONFIG_PATH", str(cfg_file))
def _reset():
ConfigManager._instance = None
ConfigManager._config = None
ConfigManager._config_path = None
_reset()
yield ConfigManager
_reset()
def _read_cfg_file() -> dict:
path = ConfigManager()._config_path
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def test_set_proxy_sanitizes_password_on_save(fresh_config, tmp_path):
"""save=True 落盘后,文件中不得出现明文密码(纵深防御脱敏)"""
cfg = fresh_config()
cfg.set_proxy("127.0.0.1", 1080, username="u1", password="secret123",
password_env="MY_SOCKS_PW", save=True)
on_disk = _read_cfg_file()
socks = on_disk["proxy"]["socks5"]
assert socks["password"] in (None, ""), "落盘文件不得含明文密码"
assert socks["password_env"] == "MY_SOCKS_PW", "password_env 应持久化"
raw = (tmp_path / "config.json").read_text(encoding="utf-8")
assert "secret123" not in raw, "整个配置文件不得出现明文密码字符串"
# 内存中本会话仍可用
proxies = cfg.get_proxy()
assert proxies is not None
assert "u1:secret123@" in proxies["https"]
def test_password_env_resolution(fresh_config, monkeypatch):
"""password 为空时按 password_env 从环境变量解析"""
monkeypatch.setenv("TEST_SOCKS_PW", "env-secret-42")
cfg = fresh_config()
cfg.set_proxy("10.0.0.8", 7890, username="u2", password=None,
password_env="TEST_SOCKS_PW", save=True)
proxies = cfg.get_proxy()
assert proxies is not None
assert "u2:env-secret-42@10.0.0.8:7890" in proxies["https"]
# get_proxy_info 不泄漏密码值,只给布尔 + 变量名
info = cfg.get_proxy_info()
assert info["password"] is True
assert info["password_env"] == "TEST_SOCKS_PW"
def test_legacy_plaintext_migration(fresh_config, tmp_path):
"""旧版明文配置:加载后盘上清除,内存保留供本会话使用"""
cfg_file = tmp_path / "config.json"
legacy = {
"proxy": {
"socks5": {
"enabled": True, "host": "1.2.3.4", "port": 1080,
"username": "old_u", "password": "legacy_plain_pw"
}
}
}
cfg_file.write_text(json.dumps(legacy, ensure_ascii=False), encoding="utf-8")
cfg = fresh_config() # 触发 _load_config → 迁移
# 盘上已脱敏
on_disk = _read_cfg_file()
assert on_disk["proxy"]["socks5"]["password"] in (None, "")
raw = cfg_file.read_text(encoding="utf-8")
assert "legacy_plain_pw" not in raw
# 内存中本会话仍可解析出旧密码(代理不断)
proxies = cfg.get_proxy()
assert proxies is not None
assert "old_u:legacy_plain_pw@1.2.3.4:1080" in proxies["https"]
+196
View File
@@ -0,0 +1,196 @@
"""subsystems.conversation_compressor 单元测试
覆盖:
- estimate_tokens: 中英文/工具调用 token 估算
- should_compress: 消息数阈值触发 / Token 阈值触发 / 均未触发 / 功能禁用
- compress_conversation: 消息数触发压缩 / Token 触发压缩 / LLM 失败保留原文
- _validate_message_structure: 移除孤立 tool 消息
通过注入 FakeLLMClient 模拟 LLM 调用,不依赖真实 API。
"""
import pytest
from subsystems import ConversationCompressor
from common import constants, Logger
class FakeLLMClient:
"""模拟 llm_client 抽象层的 chat_completion"""
def __init__(self, reply="这是压缩摘要", error=None):
self.reply = reply
self.error = error
self.calls = []
def chat_completion(self, messages, temperature=0.7, max_tokens=None, **kwargs):
self.calls.append({"max_tokens": max_tokens, "n_msgs": len(messages)})
if self.error:
raise self.error
return self.reply
def make_messages(n_pairs=15, long_content=False):
"""构造 n_pairs 轮 user/assistant 对话(含 1 条前导 system)"""
msgs = [{"role": "system", "content": "SYS_PROMPT"}]
for i in range(n_pairs):
content = f"用户消息内容{i}"
if long_content:
content += "很长的文档内容" * 200
msgs.append({"role": "user", "content": content})
msgs.append({"role": "assistant", "content": f"助手回复{i}"})
return msgs
@pytest.fixture
def compressor():
return ConversationCompressor(llm_client=FakeLLMClient(), logger=Logger("TestCompressor", 0))
def patch_const(monkeypatch, name, value):
"""把 Constants 类属性 property 替换为固定值(参照 test_db.py 既有做法)"""
monkeypatch.setattr(type(constants), name, property(lambda self: value))
# ==================== estimate_tokens ====================
def test_estimate_tokens_chinese(compressor):
assert compressor.estimate_tokens([{"role": "user", "content": "你好"}]) == 13 # 2*1.5 + 10
def test_estimate_tokens_english(compressor):
# 2 词 * 1.3 + (11 字符 - 2 词) * 0.8 + 10 = 19.8 → int 截断为 19
# (既有算法:other_chars 按词数抵扣,字母被计入 other,保持原行为)
assert compressor.estimate_tokens([{"role": "user", "content": "hello world"}]) == 19
def test_estimate_tokens_tool_calls(compressor):
msgs = [{
"role": "assistant", "content": "",
"tool_calls": [{"function": {"name": "run_command", "arguments": "{\"a\":1}"}}],
}]
# name 11 字符*1.5=16.5 + arguments 8 字符*0.8=6.4 + 50 = 72.9 → 72
assert compressor.estimate_tokens(msgs) == 72
def test_estimate_tokens_empty(compressor):
assert compressor.estimate_tokens([]) == 0
# ==================== should_compress ====================
def test_should_compress_below_thresholds(compressor, monkeypatch):
patch_const(monkeypatch, "COMPRESS_MESSAGE_THRESHOLD", 1000)
patch_const(monkeypatch, "COMPRESS_TOKEN_THRESHOLD", 10 ** 9)
should, reason = compressor.should_compress(make_messages(5))
assert should is False
def test_should_compress_by_message_count(compressor, monkeypatch):
patch_const(monkeypatch, "COMPRESS_MESSAGE_THRESHOLD", 5)
should, reason = compressor.should_compress(make_messages(5)) # 10 条对话消息
assert should is True
assert "消息数" in reason
def test_should_compress_by_token_count(compressor, monkeypatch):
"""消息数未超阈值但 Token 数超阈值时也应触发(v3.1 修复项)。"""
patch_const(monkeypatch, "COMPRESS_MESSAGE_THRESHOLD", 1000)
patch_const(monkeypatch, "COMPRESS_TOKEN_THRESHOLD", 1)
should, reason = compressor.should_compress(make_messages(5))
assert should is True
assert "Token" in reason
def test_should_compress_disabled(compressor, monkeypatch):
patch_const(monkeypatch, "COMPRESS_ENABLED", False)
should, _ = compressor.should_compress(make_messages(100))
assert should is False
# ==================== compress_conversation ====================
def test_compress_by_message_count(compressor, monkeypatch):
patch_const(monkeypatch, "COMPRESS_MESSAGE_THRESHOLD", 10)
messages = make_messages(15) # 30 条对话消息 + 1 system
result = compressor.compress_conversation(messages)
non_system = [m for m in result if m["role"] != "system"]
assert len(non_system) < 30 # 消息数下降
# 原始 system 提示词保留
assert any(m["content"] == "SYS_PROMPT" for m in result)
# 压缩摘要已注入
assert any(m["role"] == "system" and "这是压缩摘要" in m["content"] for m in result)
# 最近消息保留
assert any(m.get("content") == "助手回复14" for m in non_system)
# LLM 被调用了一次
assert len(compressor.llm_client.calls) == 1
def test_compress_by_token_count(compressor, monkeypatch):
"""Token 触发(消息数远低于阈值)时也应实际执行压缩(v3.1 修复项)。"""
patch_const(monkeypatch, "COMPRESS_MESSAGE_THRESHOLD", 1000)
patch_const(monkeypatch, "COMPRESS_TOKEN_THRESHOLD", 1)
messages = make_messages(15, long_content=True) # 30 条长消息
result = compressor.compress_conversation(messages)
non_system = [m for m in result if m["role"] != "system"]
assert len(non_system) < 30
assert any(m["role"] == "system" and "这是压缩摘要" in m["content"] for m in result)
assert len(compressor.llm_client.calls) == 1
# Token 数下降
assert compressor.estimate_tokens(non_system) < compressor.estimate_tokens(
[m for m in messages if m["role"] != "system"])
def test_compress_llm_failure_keeps_original(monkeypatch):
comp = ConversationCompressor(
llm_client=FakeLLMClient(error=RuntimeError("api down")),
logger=Logger("TestCompressor", 0),
)
patch_const(monkeypatch, "COMPRESS_MESSAGE_THRESHOLD", 10)
messages = make_messages(15)
result = comp.compress_conversation(messages)
assert result == messages # 失败时原样返回
def test_compress_without_llm_client_keeps_original(monkeypatch):
comp = ConversationCompressor(llm_client=None)
patch_const(monkeypatch, "COMPRESS_MESSAGE_THRESHOLD", 10)
messages = make_messages(15)
assert comp.compress_conversation(messages) == messages
def test_compress_empty_messages(compressor):
assert compressor.compress_conversation([]) == []
def test_compress_too_few_messages_skips(compressor, monkeypatch):
patch_const(monkeypatch, "COMPRESS_MESSAGE_THRESHOLD", 10)
messages = make_messages(3) # 6 条对话消息,压缩块必然 < 5
assert compressor.compress_conversation(messages) == messages
assert compressor.llm_client.calls == []
# ==================== _validate_message_structure ====================
def test_validate_removes_orphan_tool_messages(compressor):
msgs = [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "",
"tool_calls": [{"id": "t1", "function": {"name": "f", "arguments": "{}"}}]},
{"role": "tool", "tool_call_id": "t1", "content": "ok"},
{"role": "tool", "tool_call_id": "orphan", "content": "孤儿消息"},
]
result = compressor._validate_message_structure(msgs)
assert len(result) == 3
assert all(m.get("tool_call_id") != "orphan" for m in result)
def test_validate_keeps_paired_tool_messages(compressor):
msgs = [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "",
"tool_calls": [{"id": "t1", "function": {"name": "f", "arguments": "{}"}}]},
{"role": "tool", "tool_call_id": "t1", "content": "ok"},
]
assert len(compressor._validate_message_structure(msgs)) == 3
+81
View File
@@ -70,6 +70,87 @@ def test_memory_search_empty_keywords_returns_empty(mem_db):
assert mem_db.search_memories([]) == [] assert mem_db.search_memories([]) == []
def test_memory_search_chinese_keyword(mem_db):
"""中文关键词应可检索(trigram 分词或 LIKE 兜底)。"""
mem_db.add_memory(topic="天气查询", summary="今天天气晴朗", content="气温25度")
mem_db.add_memory(topic="会议安排", summary="下午三点开会", content="会议室A")
results = mem_db.search_memories(["天气"])
assert len(results) == 1
assert results[0]["topic"] == "天气查询"
def test_memory_search_chinese_match_all(mem_db):
"""中文多关键词 match_all:全部命中才返回。"""
mem_db.add_memory(topic="天气查询", summary="今天天气晴朗", content="气温25度")
mem_db.add_memory(topic="天气与会议", summary="开会前先查天气", content="会议室A")
results = mem_db.search_memories(["天气", "开会"], match_all=True)
assert len(results) == 1
assert results[0]["topic"] == "天气与会议"
def test_memory_search_like_escape_wildcards(mem_db):
"""LIKE 兜底路径中 %/_ 应被转义为字面字符,不当作通配符。"""
import sqlite3
mem_db.add_memory(topic="progress", summary="100% done")
mem_db.add_memory(topic="task_a", summary="plain text")
mem_db.add_memory(topic="taskXa", summary="wildcard trap")
def like_search(kw):
with sqlite3.connect(mem_db.db_path) as conn:
conn.row_factory = sqlite3.Row
return mem_db._search_via_like(conn.cursor(), [kw], match_all=False, limit=100)
# "%" 为字面字符:"100%" 只命中 progress,不当作任意串通配
results = like_search("100%")
assert [r["topic"] for r in results] == ["progress"]
# "_" 为字面字符:"ask_" 只命中 task_a;若未转义,taskXa 也会被 %ask_% 误匹配
results = like_search("ask_")
assert [r["topic"] for r in results] == ["task_a"]
def test_memory_fts_migration_from_unicode61(tmp_path):
"""旧 unicode61 FTS 表在重新打开时应自动迁移为 trigram 并回灌数据。"""
import sqlite3
db_path = str(tmp_path / "legacy.db")
# 模拟旧库:unicode61 建表 + 写入数据
conn = sqlite3.connect(db_path)
conn.execute('''CREATE TABLE memories (
id INTEGER PRIMARY KEY AUTOINCREMENT, topic TEXT NOT NULL, summary TEXT NOT NULL,
content TEXT, start_time TEXT NOT NULL, end_time TEXT NOT NULL,
created_at TEXT NOT NULL, tags TEXT, importance INTEGER DEFAULT 1)''')
conn.execute('''CREATE VIRTUAL TABLE memories_fts
USING fts5(topic, summary, content, tags, content=memories)''')
conn.execute("INSERT INTO memories (topic, summary, start_time, end_time, created_at) "
"VALUES ('天气查询', '今天天气晴朗', 't', 't', 't')")
conn.execute("INSERT INTO memories_fts (rowid, topic, summary) VALUES (1, '天气查询', '今天天气晴朗')")
conn.commit()
conn.close()
# 重新打开 → 自动迁移 → 中文可搜
db = MemoryDatabase(db_path=db_path)
results = db.search_memories(["天气"])
assert len(results) == 1
assert results[0]["topic"] == "天气查询"
# 迁移后 FTS 表应为 trigram 分词(环境支持时)
conn = sqlite3.connect(db_path)
sql = conn.execute("SELECT sql FROM sqlite_master WHERE name='memories_fts'").fetchone()[0]
fts_count = conn.execute("SELECT COUNT(*) FROM memories_fts").fetchone()[0]
conn.close()
assert fts_count == 1 # 数据已回灌
try:
probe = sqlite3.connect(":memory:")
probe.execute("CREATE VIRTUAL TABLE t USING fts5(x, tokenize='trigram')")
probe.close()
assert "trigram" in sql
except sqlite3.OperationalError:
pass # 环境不支持 trigram,保持 unicode61 属预期行为
def test_memory_delete(mem_db): def test_memory_delete(mem_db):
mid = mem_db.add_memory(topic="待删除", summary="摘要") mid = mem_db.add_memory(topic="待删除", summary="摘要")
assert mem_db.delete_memory(mid) is True assert mem_db.delete_memory(mid) is True
+171
View File
@@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""F2 修复回归测试:N6 registry 超时清理 + N4 reader 线程收尾。
N6:流式转发超时后必须清理 tool.streaming_output / tool.output_queue,
防止僵尸 daemon 线程经实例属性把输出混入后续会话队列。
N4:_read_with_limit 超时/宽限 break 后必须 close stdout + join reader,
防止 Windows shell=True 孙进程持管道场景下 reader 线程与 fd 泄漏。
"""
import queue
import threading
import time
import pytest
from subsystems.executors import SubprocessCommandExecutor
from tools.base import Tool
from tools.registry import ToolRegistry
# ---------- N6:registry 超时路径清理 ----------
class _StuckStreamTool(Tool):
"""execute_streaming 永不结束(模拟卡死的工具,如等待外部资源)"""
def get_name(self):
return "stuck_tool"
def get_description(self):
return "test only"
def get_parameters(self):
return {"type": "object", "properties": {}}
def execute(self, **kwargs):
time.sleep(30)
return "should not reach"
def execute_streaming(self, output_queue: queue.Queue, **kwargs):
self.output_queue = output_queue
self.streaming_output = True
try:
time.sleep(30) # 卡死,永不产出 complete
finally:
self.streaming_output = False
def test_registry_timeout_cleans_tool_state():
"""转发总超时后:返回超时消息,且工具实例属性被清理"""
reg = ToolRegistry()
reg.stream_tool_timeout = 1 # 测试调小(默认 3600)
tool = _StuckStreamTool()
reg.register(tool)
reg.set_output_queue(queue.Queue())
t0 = time.time()
result = reg.execute_tool("stuck_tool")
elapsed = time.time() - t0
assert "超时" in result, f"应返回超时消息,实际: {result}"
assert elapsed < 10, f"应 ~1s 返回,实际 {elapsed:.1f}s"
# N6 核心:超时路径不得跳过清理
assert tool.output_queue is None, "超时后 tool.output_queue 必须置 None(防僵尸输出串流)"
assert tool.streaming_output is False, "超时后 tool.streaming_output 必须复位"
def test_registry_normal_path_cleans_tool_state():
"""正常流式完成后:清理同样执行,结果透传"""
reg = ToolRegistry()
class _OkTool(_StuckStreamTool):
def get_name(self):
return "ok_tool"
def execute_streaming(self, output_queue: queue.Queue, **kwargs):
self.output_queue = output_queue
self.streaming_output = True
try:
output_queue.put(("line", "hello"))
output_queue.put(("complete", "done-result"))
finally:
pass
tool = _OkTool()
reg.register(tool)
reg.set_output_queue(queue.Queue())
result = reg.execute_tool("ok_tool")
assert result == "done-result"
assert tool.output_queue is None
assert tool.streaming_output is False
# ---------- N4:reader 线程收尾 ----------
class _FakeStdout:
"""模拟管道 stdout,可配置阻塞时长"""
def __init__(self, block_seconds: float = 8):
self.closed = False
self._block = block_seconds
def readline(self):
time.sleep(self._block) # > join 宽限 2s 即模拟"无 EOF 阻塞"
return ""
def close(self):
self.closed = True
class _FakeProcess:
def __init__(self, block_seconds: float = 8):
self.stdout = _FakeStdout(block_seconds)
def poll(self):
return None # 进程"活着"
def terminate(self):
pass
def kill(self):
pass
def test_read_with_limit_timeout_returns_promptly():
"""超时 break 后 reader 仍阻塞:只做有限 join,不 close、不挂死。
CPython 流锁决定了此时 close(stdout) 会阻塞到 readline 返回,
故收尾策略为放弃等待(daemon 兜底),主流程必须及时返回。
"""
ex = SubprocessCommandExecutor()
proc = _FakeProcess(block_seconds=8)
t0 = time.time()
output, timeout_occurred, truncated = ex._read_with_limit(
proc, timeout=1, start_time=time.time())
elapsed = time.time() - t0
assert timeout_occurred is True
assert truncated is False
# 超时 1s + join 宽限 2s,绝不能等到 block 8s
assert elapsed < 5, f"应收尾后及时返回,实际 {elapsed:.1f}s"
def test_read_with_limit_grace_path_returns_promptly():
"""shell 死亡宽限 break 后 reader 仍阻塞:同样及时返回"""
ex = SubprocessCommandExecutor()
proc = _FakeProcess(block_seconds=8)
proc.poll = lambda: 0 # shell 已死,但 reader 无 EOF(孙进程持管道)
t0 = time.time()
output, timeout_occurred, truncated = ex._read_with_limit(
proc, timeout=0, start_time=time.time()) # timeout=0 不触发超时
elapsed = time.time() - t0
assert timeout_occurred is False
# 宽限 1s + join 2s 上界,不能等到 block 8s
assert elapsed < 5, f"宽限+收尾应 ~3s 内返回,实际 {elapsed:.1f}s"
def test_read_with_limit_closes_stdout_after_normal_eof():
"""reader 正常收到 EOF 退出后:显式 close stdout 释放管道 fd"""
ex = SubprocessCommandExecutor()
proc = _FakeProcess(block_seconds=0) # readline 立即返回 ""(EOF)
output, timeout_occurred, truncated = ex._read_with_limit(
proc, timeout=5, start_time=time.time())
assert timeout_occurred is False
assert output == ""
assert proc.stdout.closed is True, "reader 正常退出后必须 close stdout 释放 fd"
+359
View File
@@ -0,0 +1,359 @@
"""aibox 第三轮审计 Batch G 修复回归测试(2026-08-18,审计报告 §9.4)
覆盖:
- P1:非瞬时 HTTP 错误响应体截断(密钥/超大错误页不进异常栈)
- P2:Retry-After 解析上限 cap 3600s
- P3:fallback 档案缺 model → 跳过并告警(与缺 Key 行为对齐)
- P5:LLMClient 死状态 _cancelled 已删除
- P6:fallback 列表重复项去重
- N2:drain 线程批次结束即回收(不再泄漏常驻线程)
- N5:子代理共享父代理取消事件
- N7:FTS5 关键词内双引号翻倍转义
- N8:registry 流式转发受 TOOL_MAX_RESULT_LENGTH 约束
- N10:mark_as_triggered 单事务(缺失 id 路径回归)
- N12:persona 路径边界校验(拒绝越界路径)
- N16:同秒归档不再覆盖(文件名加序号)
N3(/new 移线程池)/N13(broadcast)/N14(任务登记集合)为 async WebSocket
路径,依赖事件循环与真实连接对象,由现有 WebSocket 测试面回归,不在此单测。
"""
import os
import queue
import sys
import threading
import types
from unittest import mock
import pytest
# ---- 环境桩:websockets 未安装时保证 import aibox.py 不失败 ----
if "websockets" not in sys.modules:
sys.modules["websockets"] = types.ModuleType("websockets")
import aibox # noqa: E402
from aibox import DeepSeekChat, run_full_subagent # noqa: E402
from llm_client import ( # noqa: E402
DeepSeekClient,
FailoverClient,
_retry_after_of,
create_llm_client,
)
from subsystems.db import MemoryDatabase, MemoDatabase # noqa: E402
from subsystems.conversation_history import ConversationHistory # noqa: E402
from tools.base import Tool # noqa: E402
from tools.registry import ToolRegistry # noqa: E402
from common import constants # noqa: E402
# ==================== P1/P2:错误响应体截断 + Retry-After cap ====================
class TestP1P2LlmClient:
def test_p1_non_transient_error_body_truncated(self):
"""400 响应体 1000 字符 → 异常消息仅含前 200 字符"""
c = DeepSeekClient(api_key="k", model="m", base_url="https://x")
resp = mock.MagicMock()
resp.status_code = 400
resp.text = "A" * 1000
resp.headers = {}
with mock.patch("llm_client.requests.post", return_value=resp):
with pytest.raises(RuntimeError) as ei:
list(c.stream_chat([{"role": "user", "content": "hi"}]))
msg = str(ei.value)
assert "HTTP 400" in msg
assert len(msg) < 300 # 200 字符正文 + 状态行前缀
assert msg.count("A") <= 200
def test_p2_retry_after_capped(self):
"""异常/恶意 Retry-After=999999 → cap 到 3600s"""
resp = mock.MagicMock()
resp.headers = {"Retry-After": "999999"}
assert _retry_after_of(resp) == 3600.0
def test_p2_retry_after_normal_value_kept(self):
resp = mock.MagicMock()
resp.headers = {"Retry-After": "30"}
assert _retry_after_of(resp) == 30.0
def test_p2_retry_after_invalid_returns_none(self):
resp = mock.MagicMock()
resp.headers = {"Retry-After": "not-a-number"}
assert _retry_after_of(resp) is None
# ==================== P3/P5/P6:fallback 链构造 ====================
def _g_cfg():
"""主档案 main 的 fallback 链:缺 model 的 broken + 重复 dupe + 正常 ok2"""
return {
"models": {
"main": {
"api_url": "https://a.example/v1/chat/completions",
"model": "m1", "api_key_env": "MAIN_KEY",
"fallback": ["nomodel", "dupe", "dupe", "ok2"],
},
"nomodel": { # P3:缺 model 字段
"api_url": "https://b.example/v1/chat/completions",
"api_key_env": "B_KEY",
},
"dupe": {
"api_url": "https://c.example/v1/chat/completions",
"model": "c1", "api_key_env": "C_KEY",
},
"ok2": {
"api_url": "https://d.example/v1/chat/completions",
"model": "d1", "api_key_env": "D_KEY",
},
}
}
class TestP3P5P6FallbackChain:
def test_p3_p6_fallback_missing_model_skipped_and_deduped(self):
"""缺 model 的 fallback 跳过;重复项去重;链 = [main, dupe, ok2]"""
env = {k: f"sk-{k.lower()}" for k in
("MAIN_KEY", "B_KEY", "C_KEY", "D_KEY")}
with mock.patch.dict(os.environ, env):
c = create_llm_client(_g_cfg(), model="main")
assert isinstance(c, FailoverClient)
names = [cl.name for cl in c._clients]
assert names == ["main", "dupe", "ok2"]
def test_p5_dead_cancel_flag_removed(self):
"""LLMClient 不再持有只写不读的 _cancelled 死状态"""
c = DeepSeekClient(api_key="k", model="m", base_url="https://x")
assert not hasattr(c, "_cancelled")
c.cancel() # 无在途响应时为安全 no-op
# ==================== N2:drain 线程生命周期 ====================
class _EchoTool(Tool):
def get_name(self):
return "g_echo"
def get_description(self):
return "测试用回显工具"
def get_parameters(self):
return {"type": "object", "properties": {}}
def execute(self, **kwargs):
return "ok"
class TestN2DrainThread:
def test_drain_thread_reclaimed_after_batch(self):
"""_handle_tool_calls 返回后 drain 线程必须已退出(finally join)"""
from test_once_mode import _make_ai
ai = _make_ai()
ai.tool_registry.register(_EchoTool(ai.logger))
tool_calls = [{"id": "c1", "function": {"name": "g_echo", "arguments": "{}"}}]
ai._handle_tool_calls(tool_calls, output_callback=lambda t, c: None)
drains = [t for t in threading.enumerate() if t.name == "tool-output-drain"]
assert drains == [] or all(not t.is_alive() for t in drains)
@pytest.mark.filterwarnings("ignore::pytest.PytestUnhandledThreadExceptionWarning")
def test_drain_thread_reclaimed_on_tool_exception(self):
"""工具执行抛异常时 drain 线程同样被回收(finally 路径)"""
from test_once_mode import _make_ai
class _BoomTool(_EchoTool):
def get_name(self):
return "g_boom"
def execute(self, **kwargs):
raise RuntimeError("boom")
ai = _make_ai()
ai.tool_registry.register(_BoomTool(ai.logger))
tool_calls = [{"id": "c1", "function": {"name": "g_boom", "arguments": "{}"}}]
ai._handle_tool_calls(tool_calls, output_callback=lambda t, c: None)
drains = [t for t in threading.enumerate() if t.name == "tool-output-drain"]
assert drains == [] or all(not t.is_alive() for t in drains)
# ==================== N5:子代理取消传播 ====================
def _fake_sub_chat():
fake = mock.MagicMock()
fake.think_and_respond = mock.MagicMock(return_value="任务完成报告")
fake.stats = {}
fake.messages = []
fake._cancel_event = threading.Event()
return fake
class TestN5SubagentCancel:
def test_subagent_shares_parent_cancel_event(self):
"""cancel_event 传入 → 子代理取消事件被替换为父事件(检查点联动)"""
fake = _fake_sub_chat()
parent_event = threading.Event()
with mock.patch("aibox.DeepSeekChat", return_value=fake), \
mock.patch("aibox.create_llm_client"), \
mock.patch("aibox.SubprocessCommandExecutor"):
run_full_subagent("r", "t", None, cancel_event=parent_event)
assert fake._cancel_event is parent_event
def test_subagent_keeps_own_event_when_not_provided(self):
"""cancel_event=None → 兼容旧行为,子代理保留自身事件"""
fake = _fake_sub_chat()
own = fake._cancel_event
with mock.patch("aibox.DeepSeekChat", return_value=fake), \
mock.patch("aibox.create_llm_client"), \
mock.patch("aibox.SubprocessCommandExecutor"):
run_full_subagent("r", "t", None)
assert fake._cancel_event is own
def test_subagent_tool_registered_with_parent_event(self):
"""DeepSeekChat 注册 SubAgentTool 时注入自身 _cancel_event(N5 链路)"""
from test_once_mode import _make_ai
ai = _make_ai()
tool = next((t for t in ai.tool_registry.get_all_tools()
if t.get_name() == "create_subagent"), None)
assert tool is not None
assert tool.cancel_event is ai._cancel_event
# ==================== N7:FTS5 引号转义 ====================
class TestN7FtsQuoteEscape:
def test_keyword_with_quote_does_not_break_search(self, tmp_path):
"""关键词含双引号 → MATCH 语法合法(翻倍转义),不抛异常"""
db = MemoryDatabase(db_path=str(tmp_path / "mem.db"))
db.add_memory(topic="引号话题", summary="摘要",
content='含"引号"的记忆内容',
start_time="2026-08-18 00:00:00",
end_time="2026-08-18 00:00:01", importance=2)
res = db.search_memories(['带"引号"的关键词'])
assert isinstance(res, list) # 不抛 OperationalError 即通过
def test_normal_keyword_still_matches(self, tmp_path):
"""常规关键词检索行为不回归"""
db = MemoryDatabase(db_path=str(tmp_path / "mem.db"))
db.add_memory(topic="普通话题", summary="天气摘要",
content="今天天气晴朗",
start_time="2026-08-18 00:00:00",
end_time="2026-08-18 00:00:01", importance=2)
res = db.search_memories(["天气"])
assert len(res) >= 1
# ==================== N8:流式转发长度上限 ====================
class _BigStreamTool(Tool):
"""流式工具:发出 3 行 × 40000 字符(总 120000 > 100000 上限)"""
def get_name(self):
return "g_bigstream"
def get_description(self):
return "测试用超长流式工具"
def get_parameters(self):
return {"type": "object", "properties": {}}
def execute(self, **kwargs):
return "done"
def execute_streaming(self, output_queue, **kwargs):
for _ in range(3):
output_queue.put(("line", "x" * 40000))
output_queue.put(("complete", "done"))
class TestN8StreamingLimit:
def test_streaming_forward_truncated_to_budget(self):
registry = ToolRegistry()
registry.register(_BigStreamTool())
captured = queue.Queue()
registry.set_output_queue(captured)
result = registry.execute_tool("g_bigstream")
lines = []
while not captured.empty():
msg_type, content = captured.get_nowait()
if msg_type == "line":
lines.append(content)
budget = constants.TOOL_MAX_RESULT_LENGTH
total = sum(len(ln) for ln in lines)
# 截断标记行本身占少量额外字符(含提示文案)
assert total <= budget + 200
assert any("已停止转发" in ln or "已截断" in ln for ln in lines)
assert result == "done"
# ==================== N10:mark_as_triggered 单事务 ====================
class TestN10MarkTriggered:
def test_missing_id_returns_none_via_transaction(self, tmp_path):
"""事务内读取不存在行 → rollback + None(不再走独立 get_memo)"""
db = MemoDatabase(db_path=str(tmp_path / "memo.db"))
assert db.mark_as_triggered(99999) is None
def test_single_shot_memo_completes(self, tmp_path):
"""一次性任务触发 → completed + 返回 None(无下次提醒)"""
db = MemoDatabase(db_path=str(tmp_path / "memo.db"))
mid = db.add_memo(title="一次性", content="c",
reminder_time="2026-08-18 00:00:00")
assert mid is not None
assert db.mark_as_triggered(mid) is None
memo = db.get_memo(mid)
assert memo["is_completed"] == 1
assert memo["trigger_count"] == 1
# ==================== N12:persona 路径边界 ====================
class TestN12PersonaBoundary:
def _tool(self):
return aibox.ExpressEmotionTool()
def test_traversal_persona_rejected(self, tmp_path):
"""persona 含 ../ 越界 → 拒绝(返回空串,不读文件)"""
tool = self._tool()
skills = tmp_path / "skills"
skills.mkdir()
with mock.patch.object(type(aibox.constants), "SKILLS_DIR",
new_callable=mock.PropertyMock(return_value=str(skills))):
assert tool._load_persona("../../etc/passwd", None) == ""
assert tool._load_persona("../outside", None) == ""
def test_absolute_persona_rejected(self, tmp_path):
"""persona 为绝对路径 → 解析结果不在技能目录内,拒绝"""
tool = self._tool()
skills = tmp_path / "skills"
skills.mkdir()
with mock.patch.object(type(aibox.constants), "SKILLS_DIR",
new_callable=mock.PropertyMock(return_value=str(skills))):
assert tool._load_persona(str(tmp_path / "evil"), None) == ""
def test_normal_persona_loads(self, tmp_path):
"""合法 persona(技能目录内)正常加载"""
tool = self._tool()
skills = tmp_path / "skills"
skills.mkdir()
(skills / "ok.md").write_text("人格内容", encoding="utf-8")
with mock.patch.object(type(aibox.constants), "SKILLS_DIR",
new_callable=mock.PropertyMock(return_value=str(skills))):
assert "人格内容" in tool._load_persona("ok", None)
# ==================== N16:同秒归档防覆盖 ====================
class TestN16ArchiveCollision:
def test_same_second_archive_no_overwrite(self, tmp_path):
"""同一秒内两次归档 → 文件名加序号,互不覆盖"""
hm = ConversationHistory(save_dir=str(tmp_path))
hm.start_new_conversation(topic="t1")
hm.add_message("user", "hi", speaker="u")
p1 = hm.archive_current_conversation()
hm.start_new_conversation(topic="t2")
hm.add_message("user", "hi2", speaker="u")
p2 = hm.archive_current_conversation()
assert p1 != p2
assert os.path.exists(p1) and os.path.exists(p2)
+408
View File
@@ -0,0 +1,408 @@
"""aibox 第四轮审计 Batch H 修复回归测试(2026-08-18,审计报告 §十一)
覆盖:
- H1:子代理共享父取消事件后首轮 think 不再清除父事件(N5 回归)+ 父已取消时跳过启动
- M3:调度器任务级超时兜底(AI think 卡死不再停摆调度循环)
- M4:get_recent_memories(limit=None) 使用默认上限(不再全表加载)
- M5:memo delete 物理删除(此前只标记完成)
- M6:PromptManager.reload 原子替换(并发读者不再读到空/半截列表)
- L1:read_file 的 sed 参数强转 int(防引号注入)
- L2:set_proxy 落盘失败如实报告(不再静默报"已保存")
- L3:SSE 非 UTF-8 行不中断流(errors=replace)
- L4:persona 路径校验解析符号链接(软链越界拒绝)
- L5:1MB 截断不再产生半个字符(�)
- L6:compressor 空内容压缩不再除零
- L7:InstallSkillTool 指引命令路径引用(防拆词/注入)
- L8:ask_user 缺参返回明确错误(不再 TypeError)
- L10:web 工具仅允许 http/https(file:// 等拒绝)
- L11:registry 日志打码改包含匹配(apiKey/access_token 等变体)
- L12:FailoverClient 日志 Bearer 令牌脱敏
M1(/reload_prompts 移线程池)/M2(技能扫描启动前预热)为 async WebSocket /
启动路径,依赖事件循环与真实连接对象,由既有 WebSocket 测试面与启动逻辑回归。
"""
import asyncio
import os
import queue
import sys
import threading
import types
from unittest import mock
import pytest
# ---- 环境桩:websockets 未安装时保证 import aibox.py 不失败 ----
if "websockets" not in sys.modules:
sys.modules["websockets"] = types.ModuleType("websockets")
import aibox # noqa: E402
from aibox import DeepSeekChat, TaskScheduler, run_full_subagent # noqa: E402
from llm_client import DeepSeekClient, FailoverClient, _sanitize_log_msg # noqa: E402
from subsystems.db import MemoryDatabase, MemoDatabase # noqa: E402
from subsystems.prompt_manager import PromptManager # noqa: E402
from subsystems.conversation_compressor import ConversationCompressor # noqa: E402
from common import constants # noqa: E402
from test_conversation_compressor import FakeLLMClient # noqa: E402
from test_once_mode import _make_ai # noqa: E402
def _fake_sub_chat():
fake = mock.MagicMock()
fake.think_and_respond = mock.MagicMock(return_value="任务完成报告")
fake.stats = {}
fake.messages = []
fake._cancel_event = threading.Event()
return fake
# ==================== H1:子代理取消事件清除回归 ====================
class TestH1SubagentCancelClear:
def test_shared_event_not_cleared_on_first_think(self):
"""共享父事件后首轮 think 不清除事件——父级已置位的取消信号不被吞掉"""
ai = _make_ai()
parent_event = threading.Event()
parent_event.set()
ai._cancel_event = parent_event
ai._shared_cancel_event = True
with pytest.raises(InterruptedError):
ai._think_and_respond_impl("hi")
assert parent_event.is_set()
def test_own_event_still_cleared_on_first_think(self):
"""未共享(普通会话)时首轮仍清除自身事件——原语义不回归"""
import contextlib
ai = _make_ai()
ai._cancel_event.set()
with contextlib.suppress(Exception):
ai._think_and_respond_impl("hi")
assert not ai._cancel_event.is_set()
def test_subagent_not_started_when_parent_already_cancelled(self):
"""父事件已置位 → 直接返回取消提示,不启动子代理"""
fake = _fake_sub_chat()
parent_event = threading.Event()
parent_event.set()
with mock.patch("aibox.DeepSeekChat", return_value=fake), \
mock.patch("aibox.create_llm_client"), \
mock.patch("aibox.SubprocessCommandExecutor"):
result = run_full_subagent("r", "t", None, cancel_event=parent_event)
assert "取消" in result
fake.think_and_respond.assert_not_called()
# ==================== M3:调度器任务超时 ====================
class _BlockingAI:
"""think 卡死直到被 cancel 的伪 AI(模拟 LLM 挂起)"""
def __init__(self):
self.cancelled = threading.Event()
self.cancel_called = 0
def cancel(self):
self.cancel_called += 1
self.cancelled.set()
def think_and_respond(self, input_text, output_callback=None,
is_continuation=False):
self.cancelled.wait(timeout=15)
raise InterruptedError("用户中断")
class _FakeMemoDB:
def get_memo(self, memo_id):
return None
class TestM3SchedulerTimeout:
def test_stuck_task_times_out_and_scheduler_continues(self):
from aibox import ConfigManager
loop = asyncio.new_event_loop()
try:
scheduler = TaskScheduler(loop=loop, logger=None)
fake_ai = _BlockingAI()
memo = {"id": 7, "title": "卡死任务", "content": "内容", "trigger_count": 0}
orig_get = ConfigManager.get
def fake_get(self, key, default=None):
# 只把任务超时压到 0.3s,其余配置读取走原实现(防止
# DATETIME_FORMAT 等属性读到错误类型)
if key == "scheduler.task_timeout":
return 0.3
return orig_get(self, key, default)
with mock.patch.object(ConfigManager, "get", fake_get), \
mock.patch.object(scheduler, "_create_task_ai", return_value=fake_ai):
result = loop.run_until_complete(
scheduler.execute_task_as_conversation(memo, _FakeMemoDB()))
assert "超时" in result
assert fake_ai.cancel_called >= 1
finally:
loop.close()
# ==================== M4/M5:记忆上限与任务删除 ====================
class TestM4M5Db:
def test_recent_memories_none_capped(self, tmp_path, monkeypatch):
"""limit=None 不再全表加载,使用默认上限(对齐 search_memories)"""
from test_conversation_compressor import patch_const
db = MemoryDatabase(db_path=str(tmp_path / "m.db"))
for i in range(7):
db.add_memory(topic=f"t{i}", summary=f"s{i}", content="c",
start_time="2026-08-18 00:00:00",
end_time="2026-08-18 00:00:01", importance=1)
patch_const(monkeypatch, "MEMORY_MAX_SEARCH_RESULTS", 5)
res = db.get_recent_memories(limit=None)
assert len(res) == 5
def test_delete_memo_physically_removes(self, tmp_path):
"""delete_memo 真实删除行(此前 complete_memo 只标记完成)"""
db = MemoDatabase(db_path=str(tmp_path / "m.db"))
mid = db.add_memo(title="待删任务", content="内容")
assert db.get_memo(mid) is not None
assert db.delete_memo(mid) is True
assert db.get_memo(mid) is None
assert db.delete_memo(mid) is False # 已不存在
def test_memo_tool_delete_uses_physical_delete(self, tmp_path):
"""MemoTool delete 走 delete_memo:行消失而非仅标记完成"""
from tools import MemoTool
db = MemoDatabase(db_path=str(tmp_path / "m.db"))
mid = db.add_memo(title="重复任务", content="内容", repeat_type="daily")
tool = MemoTool(db, None)
result = tool.execute(action="delete", memo_id=mid, confirm=True)
assert "已删除" in result
assert db.get_memo(mid) is None
stats = db.get_memo_stats()
assert stats["total"] == 0
# ==================== M6:PromptManager 原子 reload ====================
class TestM6PromptReloadAtomic:
def test_reload_builds_new_list_then_swaps(self):
"""reload 在本地新列表构建完成后才原子替换——读者始终看到完整列表"""
pm = PromptManager()
old_list = pm._prompts
seen = []
def fake_load(target):
seen.append(target)
target.append({"file": "X_test.md", "content": "y"})
with mock.patch.object(pm, "_load_prompts", side_effect=fake_load):
n = pm.reload()
assert len(seen) == 1
assert seen[0] is not old_list # 构建用的是新列表
assert pm._prompts is seen[0] # 完成后才替换
assert n == 1
# ==================== L1:read_file 参数强转 ====================
class TestL1ReadFileCoercion:
def test_non_integer_lines_rejected(self, tmp_path):
from tools import ReadFileTool
f = tmp_path / "f.txt"
f.write_text("line1\nline2\n", encoding="utf-8")
tool = ReadFileTool()
r = tool.execute(filepath=str(f), start_line="1'; rm -rf /; echo '",
end_line=5)
assert "参数错误" in r
def test_valid_integers_still_work(self, tmp_path):
from tools import ReadFileTool
f = tmp_path / "f.txt"
f.write_text("line1\nline2\n", encoding="utf-8")
tool = ReadFileTool()
r = tool.execute(filepath=str(f), start_line=1, end_line=2)
assert "sed -n" in r and "参数错误" not in r
# ==================== L2:set_proxy 落盘失败如实报告 ====================
class TestL2ProxySaveFailure:
def test_set_proxy_returns_false_on_save_failure(self, tmp_path):
from common import ConfigManager
cm = ConfigManager()
cm._config_path = str(tmp_path / "config.json")
with mock.patch("builtins.open", side_effect=PermissionError("disk ro")):
ok = cm.set_proxy("127.0.0.1", 1080, save=True)
assert ok is False
def test_proxy_tool_reports_save_failure(self, tmp_path):
from tools import SetProxyTool
tool = SetProxyTool(None)
with mock.patch("builtins.open", side_effect=PermissionError("disk ro")):
r = tool.execute(host="127.0.0.1", port=1080, save=True,
skip_check=True, enabled=True)
assert "保存失败" in r
# ==================== L3:SSE 非 UTF-8 行不中断流 ====================
class TestL3SseDecode:
def test_non_utf8_line_does_not_break_stream(self):
c = DeepSeekClient(api_key="k", model="m", base_url="https://x")
resp = mock.MagicMock()
resp.status_code = 200
resp.iter_lines.return_value = [
b"data: \xff\xfe",
b'data: {"choices":[{"delta":{"content":"ok"}}]}',
b"data: [DONE]",
]
with mock.patch("llm_client.requests.post", return_value=resp):
events = list(c.stream_chat([{"role": "user", "content": "hi"}]))
assert any(e.get("type") == "content" and e.get("text") == "ok"
for e in events)
# ==================== L4:persona 软链越界 ====================
class TestL4PersonaSymlink:
def test_symlink_outside_skills_dir_rejected(self, tmp_path, monkeypatch):
from test_conversation_compressor import patch_const
from aibox import ExpressEmotionTool
skills_dir = tmp_path / "skills"
skills_dir.mkdir()
outside = tmp_path / "outside.md"
outside.write_text("秘密内容", encoding="utf-8")
(skills_dir / "evil.md").symlink_to(outside)
tool = ExpressEmotionTool()
patch_const(monkeypatch, "SKILLS_DIR", str(skills_dir))
content = tool._load_persona("evil", "")
assert content == ""
def test_normal_persona_still_loads(self, tmp_path, monkeypatch):
from test_conversation_compressor import patch_const
from aibox import ExpressEmotionTool
skills_dir = tmp_path / "skills"
skills_dir.mkdir()
(skills_dir / "happy.md").write_text("快乐人格", encoding="utf-8")
tool = ExpressEmotionTool()
patch_const(monkeypatch, "SKILLS_DIR", str(skills_dir))
content = tool._load_persona("happy", "")
assert content == "快乐人格"
# ==================== L5:截断无半个字符 ====================
class TestL5NoPartialChar:
def test_truncated_output_has_no_replacement_char(self):
from subsystems.executors import SubprocessCommandExecutor
ex = SubprocessCommandExecutor()
r = ex.execute("python3 -c \"print('中' * 600000)\"", timeout=60)
assert "截断" in r
assert "\ufffd" not in r
@pytest.mark.skipif(os.name != "posix", reason="Linux 进程组语义")
def test_grandchild_holding_pipe_is_reaped(self):
"""`(sleep N &) ; echo done` 形态:shell 退出后孙进程持管道写端,
宽限窗口到期强杀进程组——不悬挂 reader、孙进程被清理"""
import subprocess as sp
from subsystems.executors import SubprocessCommandExecutor
marker = f"sleep {12340}"
ex = SubprocessCommandExecutor()
r = ex.execute(f"({marker} &) ; echo done", timeout=8)
assert "done" in r
leftover = sp.run(["pgrep", "-f", marker],
capture_output=True, text=True).stdout.strip()
assert leftover == ""
# ==================== L6:compressor 除零 ====================
class TestL6CompressorDivZero:
def test_empty_content_compress_no_div0(self, monkeypatch):
comp = ConversationCompressor(llm_client=FakeLLMClient(),
logger=None)
from test_conversation_compressor import patch_const
patch_const(monkeypatch, "COMPRESS_MESSAGE_THRESHOLD", 10)
messages = [{"role": "system", "content": "SYS"}] + [
{"role": "user" if i % 2 == 0 else "assistant", "content": ""}
for i in range(15)
]
result = comp.compress_conversation(messages)
assert isinstance(result, list)
# ==================== L7:InstallSkillTool 路径引用 ====================
class TestL7SkillPathQuoting:
def test_guide_commands_quote_paths(self):
from tools import InstallSkillTool
tool = InstallSkillTool()
r = tool.execute(skill_path="/tmp/a b; rm -rf /")
assert "ls -l '/tmp/a b; rm -rf /'" in r
assert "cp '/tmp/a b; rm -rf /' " in r
# ==================== L8:ask_user 缺参 ====================
class TestL8AskUser:
def test_missing_question_returns_error(self):
from tools import AskUserTool
tool = AskUserTool()
assert "需要提供问题" in tool.execute()
def test_question_passed_through(self):
from tools import AskUserTool
tool = AskUserTool()
assert "测试问题" in tool.execute(question="测试问题")
# ==================== L10:web 工具 scheme 白名单 ====================
class TestL10WebScheme:
def test_file_url_rejected(self):
from tools import SimpleWebCrawlerTool
tool = SimpleWebCrawlerTool()
r = tool.execute(action="get", url="file:///etc/shadow")
assert "仅支持 http/https" in r
def test_http_url_accepted(self):
from tools import SimpleWebCrawlerTool
tool = SimpleWebCrawlerTool()
r = tool.execute(action="get", url="https://example.com")
assert "curl" in r and "仅支持" not in r
# ==================== L11:registry 打码包含匹配 ====================
class TestL11SanitizeSubstring:
def test_variant_keys_redacted(self):
from tools.registry import _sanitize_kwargs
out = _sanitize_kwargs({
"apiKey": "x", "access_token": "y", "passwd": "z",
"url": "https://ok",
})
assert out == {"apiKey": "***", "access_token": "***",
"passwd": "***", "url": "https://ok"}
def test_normal_keys_untouched(self):
from tools.registry import _sanitize_kwargs
out = _sanitize_kwargs({"filepath": "/tmp/a", "question": "hi"})
assert out == {"filepath": "/tmp/a", "question": "hi"}
# ==================== L12:failover 日志 Bearer 脱敏 ====================
class TestL12LogSanitize:
def test_sanitize_msg_redacts_bearer(self):
msg = "瞬时故障: HTTP 401: Bearer sk-abc123xyz"
out = _sanitize_log_msg(msg)
assert "sk-abc123xyz" not in out
assert "Bearer ***" in out
def test_failover_log_redacts_bearer(self):
logger = mock.MagicMock()
c = FailoverClient([], logger=logger)
c._log("warning", "瞬时故障(Bearer sk-secret456)")
logged = logger.warning.call_args[0][0]
assert "sk-secret456" not in logged
assert "Bearer ***" in logged
+227
View File
@@ -0,0 +1,227 @@
"""aibox 第四轮审计 Batch I 修复回归测试(2026-08-18,审计报告 §十二)
覆盖:
- N9:prompt 写入内容长度上限(持久化注入面约束)
- Q7b:load_conversation 越界路径拒绝(realpath 前缀校验)
- F5:run_light_subagent 取消传播(流式可中断 + 父已取消早退)
- Q5:提醒时间格式异常的任务按到期返回(不再静默丢弃)
- Q8:对话文件原子写(无 .tmp 残留、JSON 完整)
- F6:WebSocketHandler 命令专用线程池存在且可释放
- limit=0 语义修正(None 才不加 LIMIT)
Q4(busy_timeout 10s)为时序性行为(默认 5s 等待已能通过短竞争),
不做慢速单测;F9(--once 硬超时)为进程级行为,由 CLI 集成面覆盖。
"""
import os
import queue
import sqlite3
import sys
import threading
import time
import types
from pathlib import Path
from unittest import mock
import pytest
# ---- 环境桩:websockets 未安装时保证 import aibox.py 不失败 ----
if "websockets" not in sys.modules:
sys.modules["websockets"] = types.ModuleType("websockets")
import aibox # noqa: E402
from aibox import run_light_subagent # noqa: E402
from subsystems.db import MemoryDatabase, MemoDatabase # noqa: E402
from subsystems.prompt_manager import PromptManager # noqa: E402
from subsystems.conversation_history import ConversationHistory # noqa: E402
from tools.prompt_tools import ( # noqa: E402
UpdateIdentityTool,
UpdateSoulTool,
SaveMemoryAndEndConversationTool,
)
# ==================== N9:prompt 写入长度上限 ====================
class TestN9PromptContentCap:
def test_update_identity_oversized_rejected(self, tmp_path, monkeypatch):
"""manager 层兜底:超长身份内容拒绝写入"""
pm = PromptManager()
with mock.patch.object(pm, "_prompts_dir", str(tmp_path)):
assert pm.update_identity("x" * (PromptManager.MAX_CONTENT_LENGTH + 1)) is False
assert not (tmp_path / pm.IDENTITY_FILE).exists()
def test_update_identity_tool_friendly_error(self, tmp_path):
"""工具层:超长内容返回明确提示而非写入"""
pm = PromptManager()
with mock.patch.object(pm, "_prompts_dir", str(tmp_path)):
tool = UpdateIdentityTool(pm, None)
r = tool.execute(content="x" * (PromptManager.MAX_CONTENT_LENGTH + 1))
assert "内容过长" in r
def test_update_soul_tool_friendly_error(self, tmp_path):
pm = PromptManager()
with mock.patch.object(pm, "_prompts_dir", str(tmp_path)):
tool = UpdateSoulTool(pm, None)
r = tool.execute(content="y" * (PromptManager.MAX_CONTENT_LENGTH + 1))
assert "内容过长" in r
def test_save_memory_status_oversized_rejected(self, tmp_path):
"""save_memory_and_end_conversation:状态内容超长拒绝"""
pm = PromptManager()
with mock.patch.object(pm, "_prompts_dir", str(tmp_path)):
tool = SaveMemoryAndEndConversationTool(
exit_callback=lambda *a: None,
memory_db=None, prompt_manager=pm, logger=None)
r = tool.execute(summary="这是一段足够长的对话总结" * 20,
current_status="x" * PromptManager.MAX_CONTENT_LENGTH)
assert "状态内容过长" in r
def test_normal_content_still_written(self, tmp_path):
pm = PromptManager()
with mock.patch.object(pm, "_prompts_dir", str(tmp_path)):
assert pm.update_identity("正常身份内容") is True
assert "正常身份内容" in (tmp_path / pm.IDENTITY_FILE).read_text(encoding="utf-8")
# ==================== Q7b:load_conversation 路径校验 ====================
class TestQ7bLoadConversationBoundary:
def test_traversal_rejected(self, tmp_path):
h = ConversationHistory(save_dir=str(tmp_path / "save"), load_history=False)
secret = tmp_path / "secret.json"
secret.write_text('{"leak": true}', encoding="utf-8")
assert h.load_conversation("../secret.json") is None
def test_normal_file_still_loads(self, tmp_path):
h = ConversationHistory(save_dir=str(tmp_path / "save"), load_history=False)
h.current_conversation = {
"id": "1", "start_time": "2026-08-18 00:00:00", "end_time": None,
"topic": "t", "messages": [{"role": "user", "content": "hi"}],
"tool_calls": [], "stats": {},
}
h.save_current_conversation()
h.archive_current_conversation()
archives = list(h.archive_dir.glob("conversation_*.json"))
assert archives
loaded = h.load_conversation(archives[0].name)
assert loaded is not None and loaded["id"] == "1"
# ==================== F5:run_light_subagent 取消传播 ====================
class _BlockingLightClient:
"""流式产出首段后卡住,直到 cancel() 才中断的伪 LLM 客户端"""
model = "fake-model"
default_temperature = None
def __init__(self):
self.cancelled = threading.Event()
def cancel(self):
self.cancelled.set()
def stream_chat(self, messages, **kwargs):
yield {"type": "content", "text": "开头"}
while not self.cancelled.wait(timeout=0.1):
pass
raise RuntimeError("连接已中断(cancel)")
class TestF5LightSubagentCancel:
def test_cancelled_before_start_returns_early(self):
"""父事件已置位 → 直接返回取消提示,不创建客户端"""
parent = threading.Event()
parent.set()
with mock.patch("aibox.create_llm_client") as mk:
r = run_light_subagent("r", "t", None, cancel_event=parent)
assert "取消" in r
mk.assert_not_called()
def test_cancel_during_stream_aborts(self):
"""流式中父级取消 → 观察者线程 cancel 客户端 → 立即中断返回"""
parent = threading.Event()
fake = _BlockingLightClient()
with mock.patch("aibox.create_llm_client", return_value=fake):
result_holder = {}
def _run():
result_holder["r"] = run_light_subagent(
"r", "t", None, cancel_event=parent)
th = threading.Thread(target=_run, daemon=True)
th.start()
time.sleep(0.5) # 等待首段产出、观察者就绪
parent.set()
th.join(timeout=5)
assert not th.is_alive() # 未挂死
assert "失败" in result_holder.get("r", "")
def test_express_emotion_registered_with_parent_event(self):
"""DeepSeekChat 注册 ExpressEmotionTool 时注入自身取消事件"""
from test_once_mode import _make_ai
ai = _make_ai()
tool = next((t for t in ai.tool_registry.get_all_tools()
if t.get_name() == "express_emotion"), None)
assert tool is not None
assert tool.cancel_event is ai._cancel_event
# ==================== Q5:格式异常任务不丢弃 ====================
class TestQ5MalformedReminderTime:
def test_malformed_time_task_returned_as_due(self, tmp_path):
db = MemoDatabase(db_path=str(tmp_path / "m.db"))
mid = db.add_memo(title="格式异常", content="c", reminder_time="不是时间")
pending = db.get_pending_memos(before_time="2026-08-18 00:00:00")
assert any(m["id"] == mid for m in pending)
def test_normal_pending_still_filtered(self, tmp_path):
db = MemoDatabase(db_path=str(tmp_path / "m.db"))
db.add_memo(title="未来任务", content="c", reminder_time="2099-01-01 00:00:00")
pending = db.get_pending_memos(before_time="2026-08-18 00:00:00")
assert pending == []
# ==================== Q8:对话文件原子写 ====================
class TestQ8AtomicWrites:
def test_no_tmp_leftover_and_valid_json(self, tmp_path):
h = ConversationHistory(save_dir=str(tmp_path / "save"), load_history=False)
h.current_conversation = {
"id": "1", "start_time": "2026-08-18 00:00:00", "end_time": None,
"topic": "t", "messages": [], "tool_calls": [], "stats": {},
}
h.save_current_conversation()
assert h.current_file.exists() # 文件名可能被用户配置覆盖,用实例属性
import json
json.loads(h.current_file.read_text(encoding="utf-8")) # 不抛即合法 JSON
h.archive_current_conversation() # 归档后 current 文件按设计移除
assert list(Path(tmp_path).rglob("*.tmp")) == []
# ==================== F6:WS 命令专用线程池 ====================
class TestF6CmdExecutor:
def test_handler_has_dedicated_executor(self):
from aibox import WebSocketHandler
h = WebSocketHandler(ai_manager=None)
assert h._cmd_executor is not None
h.shutdown()
assert h._cmd_executor is None
# ==================== limit=0 语义 ====================
class TestLimitZeroSemantics:
def test_pending_memos_limit_zero_returns_none(self, tmp_path):
db = MemoDatabase(db_path=str(tmp_path / "m.db"))
db.add_memo(title="t", content="c", reminder_time="2026-01-01 00:00:00")
assert db.get_pending_memos(limit=0, before_time="2026-08-18 00:00:00") == []
def test_immediate_memos_limit_zero_returns_none(self, tmp_path):
db = MemoDatabase(db_path=str(tmp_path / "m.db"))
db.add_memo(title="t", content="c", is_immediate=True)
assert db.get_immediate_memos(limit=0) == []
+198
View File
@@ -0,0 +1,198 @@
"""aibox 模型接入点管理工具(model_config.py)单元测试(2026-08-18)
覆盖:
- 列表归一化(默认标记/免密/失败转移展示字段)
- 添加:字段校验(档案名/地址/模型名/重复)、Key 两种方式、温度/超时钳制、
失败转移引用校验、设为默认、落盘(含脱敏)
- 删除:物理移除、fallback 悬空引用清理、默认档案标记清除
- 连通性验证:成功 / 失败 / 档案不存在
"""
import sys
import types
from unittest import mock
import pytest
if "websockets" not in sys.modules:
sys.modules["websockets"] = types.ModuleType("websockets")
import model_config # noqa: E402
from common import ConfigManager # noqa: E402
def _cfg(tmp_path, initial: dict = None) -> ConfigManager:
"""构造指向临时配置文件的 ConfigManager(避免碰真实 ~/.aibox)"""
cm = ConfigManager()
cm._config_path = str(tmp_path / "config.json")
cm._config = initial if initial is not None else {}
return cm
def _base_models():
return {
"models": {
"deepseek": {
"api_url": "https://api.deepseek.com/v1/chat/completions",
"model": "deepseek-chat",
"api_key_env": "DEEPSEEK_API_KEY",
},
"local": {
"api_url": "http://localhost:11434/v1/chat/completions",
"model": "qwen3",
"api_key_env": "",
},
},
"api": {"default_model": "deepseek"},
}
# ==================== 列表 ====================
class TestListProfiles:
def test_list_normalized(self, tmp_path):
cm = _cfg(tmp_path, _base_models())
profiles = model_config.list_profiles(cm)
assert len(profiles) == 2
ds = next(p for p in profiles if p["name"] == "deepseek")
assert ds["is_default"] is True
assert ds["api_key_env"] == "DEEPSEEK_API_KEY"
local = next(p for p in profiles if p["name"] == "local")
assert local["api_key_env"] == ""
assert local["is_default"] is False
def test_list_empty_models_section(self, tmp_path):
cm = _cfg(tmp_path, {})
assert model_config.list_profiles(cm) == []
def test_list_ignores_malformed_entries(self, tmp_path):
cm = _cfg(tmp_path, {"models": {"bad": "not-a-dict"}})
assert model_config.list_profiles(cm) == []
# ==================== 校验 ====================
class TestValidateNewProfile:
def test_name_rules(self, tmp_path):
cm = _cfg(tmp_path, _base_models())
assert model_config.validate_new_profile(cm, "", "https://x", "m")
assert model_config.validate_new_profile(cm, "坏名字!", "https://x", "m")
assert model_config.validate_new_profile(cm, "deepseek", "https://x", "m") # 已存在
assert model_config.validate_new_profile(cm, "ok_name", "https://x", "m") is None
def test_url_scheme_and_model_required(self, tmp_path):
cm = _cfg(tmp_path, _base_models())
assert model_config.validate_new_profile(cm, "kimi", "ftp://x", "m")
assert model_config.validate_new_profile(cm, "kimi", "https://x", "")
# ==================== 添加 ====================
class TestAddProfile:
def test_add_with_env_key_and_default(self, tmp_path):
cm = _cfg(tmp_path, _base_models())
msgs = model_config.add_profile(
cm, "kimi", "https://api.moonshot.cn/v1/chat/completions",
"moonshot-v1-8k", api_key_env="KIMI_KEY",
display_name="Kimi", set_default=True)
assert any("已添加" in m for m in msgs)
assert any("设为默认" in m for m in msgs)
assert cm.get("api.default_model") == "kimi"
assert cm.get("models.kimi.api_key_env") == "KIMI_KEY"
assert cm.get("models.kimi.api_url").startswith("https://")
# 落盘文件存在且可解析
import json
disk = json.loads((tmp_path / "config.json").read_text(encoding="utf-8"))
assert disk["models"]["kimi"]["model"] == "moonshot-v1-8k"
def test_add_without_key_is_exempt_endpoint(self, tmp_path):
cm = _cfg(tmp_path, {})
msgs = model_config.add_profile(
cm, "local", "http://localhost:11434/v1/chat/completions",
"qwen3", api_key_env="")
assert any("免密" in m for m in msgs)
assert cm.get("models.local.api_key_env") == ""
def test_add_rejects_invalid_fallback(self, tmp_path):
cm = _cfg(tmp_path, _base_models())
msgs = model_config.add_profile(
cm, "kimi", "https://x/v1", "m1",
fallback=["ghost", "deepseek"])
assert any("失败转移" in m for m in msgs)
assert cm.get("models.kimi") is None # 未写入
def test_add_temperature_timeout_clamped(self, tmp_path):
cm = _cfg(tmp_path, {})
model_config.add_profile(cm, "a", "https://x/v1", "m1",
temperature=9.9, timeout=1)
assert cm.get("models.a.temperature") == 2.0
assert cm.get("models.a.timeout") == 5
def test_add_invalid_temperature_rejected(self, tmp_path):
cm = _cfg(tmp_path, {})
msgs = model_config.add_profile(cm, "a", "https://x/v1", "m1",
temperature="abc")
assert any("温度" in m for m in msgs)
assert cm.get("models.a") is None
# ==================== 删除 ====================
class TestDeleteProfile:
def test_delete_cleans_fallback_refs(self, tmp_path):
cm = _cfg(tmp_path, {
"models": {
"main": {"api_url": "https://a", "model": "m1",
"fallback": ["backup"]},
"backup": {"api_url": "https://b", "model": "m2"},
},
})
msgs = model_config.delete_profile(cm, "backup")
assert any("已删除" in m for m in msgs)
assert any("失败转移引用" in m for m in msgs)
assert cm.get("models.main.fallback") == []
def test_delete_default_clears_default_flag(self, tmp_path):
cm = _cfg(tmp_path, _base_models())
model_config.delete_profile(cm, "deepseek")
assert cm.get("api.default_model") is None
def test_delete_nonexistent(self, tmp_path):
cm = _cfg(tmp_path, _base_models())
msgs = model_config.delete_profile(cm, "ghost")
assert any("不存在" in m for m in msgs)
# ==================== 连通性验证 ====================
class TestTestProfile:
def test_connectivity_ok(self, tmp_path):
cm = _cfg(tmp_path, _base_models())
fake = mock.MagicMock()
fake.chat_completion.return_value = "pong"
with mock.patch("llm_client.create_llm_client", return_value=fake):
r = model_config.test_profile(cm, "deepseek")
assert "连通正常" in r and "pong" in r
def test_connectivity_failure(self, tmp_path):
cm = _cfg(tmp_path, _base_models())
fake = mock.MagicMock()
fake.chat_completion.side_effect = RuntimeError("连接被拒绝")
with mock.patch("llm_client.create_llm_client", return_value=fake):
r = model_config.test_profile(cm, "deepseek")
assert "验证失败" in r and "连接被拒绝" in r
def test_connectivity_missing_profile(self, tmp_path):
cm = _cfg(tmp_path, _base_models())
assert "不存在" in model_config.test_profile(cm, "ghost")
# ==================== 落盘失败感知 ====================
class TestSaveFailure:
def test_add_reports_save_failure(self, tmp_path):
cm = _cfg(tmp_path, {})
with mock.patch("builtins.open", side_effect=PermissionError("disk ro")):
msgs = model_config.add_profile(
cm, "a", "https://x/v1", "m1", api_key_env="K")
assert any("保存失败" in m for m in msgs)
File diff suppressed because it is too large. Load diff
+269
View File
@@ -0,0 +1,269 @@
"""aibox CLI 一次性对话模式(--once)单元测试
覆盖:
- handle_once_mode 参数路由:未传 --once → 返回 False
- enable_memory=False → MemoryTool 不注册(干净禁用)
- enable_memory=True(默认)→ MemoryTool 正常注册(回归保证)
- cli_callback 流式分流:chunk→stdout / line→stderr / reasoning 受 --show-reasoning 控制
- 退出码:成功 0 / 工具失败 1 / API 失败 2
- stdout 不被压缩提示等 print 污染
通过 FakeLLMClient 桩掉 stream_chat,不依赖真实 API/网络。
"""
import sys
import types
import argparse
from unittest import mock
import pytest
# ---- 环境桩:websockets 未安装时保证 import aibox.py 不失败 ----
if "websockets" not in sys.modules:
sys.modules["websockets"] = types.ModuleType("websockets")
import aibox # noqa: E402
from aibox import DeepSeekChat, handle_once_mode # noqa: E402
# ==================== Fake LLM 客户端 ====================
class FakeLLMClient:
"""桩 LLMClient:按预设脚本产出统一事件流。"""
def __init__(self, events=None, raise_exc=None):
# events: list[dict],如 [{"type":"content","text":"你好"}, {"type":"done"}]
self.events = events if events is not None else [
{"type": "content", "text": "默认回答"},
{"type": "done"},
]
self.raise_exc = raise_exc
self.api_key = "fake-key"
self.name = "fake-model"
self.model = "fake-model"
def stream_chat(self, messages, temperature=0.7, max_tokens=8192,
tools=None, tool_choice=None, reasoning_enabled=False, **extra):
if self.raise_exc:
raise self.raise_exc
for ev in self.events:
yield ev
def chat_completion(self, messages, temperature=0.3, max_tokens=1024, **kwargs):
return "压缩摘要"
def cancel(self):
pass
# ==================== 公共工具 ====================
def _make_ai(events=None, raise_exc=None, enable_memory=True,
tool_profile=None):
"""构造一个注入 FakeLLMClient 的 DeepSeekChat,跳过真实网络/文件系统副作用。"""
from aibox import TOOL_PROFILE_ORCHESTRATOR
with mock.patch("aibox.create_llm_client") as mk:
fake = FakeLLMClient(events=events, raise_exc=raise_exc)
mk.return_value = fake
with mock.patch("aibox.PromptManager") as pm, \
mock.patch("aibox.SkillManager"):
pm.return_value = mock.MagicMock()
pm.return_value.get_all_prompts.return_value = []
ai = DeepSeekChat(
api_key="fake",
history_manager=None,
memory_db=mock.MagicMock(),
memo_db=mock.MagicMock(),
command_executor=mock.MagicMock(),
prompt_manager=pm.return_value,
enable_memory=enable_memory,
tool_profile=tool_profile or TOOL_PROFILE_ORCHESTRATOR,
)
return ai
def _ns(**over):
"""构造 argparse.Namespace,含 once 模式全部字段默认值。"""
base = dict(once=None, quiet=False, once_timeout=300,
no_save_memory=False, show_reasoning=False,
worker_only=False, model=None, temperature=None)
base.update(over)
return argparse.Namespace(**base)
# ==================== enable_memory 开关 ====================
class TestEnableMemory:
def test_memory_tool_registered_by_default(self):
"""默认 enable_memory=True → memory 工具注册"""
ai = _make_ai(enable_memory=True)
names = [t.get_name() for t in ai.tool_registry.get_all_tools()]
assert "memory" in names
def test_memory_tool_disabled(self):
"""enable_memory=False → memory 工具不注册(干净禁用)"""
ai = _make_ai(enable_memory=False)
names = [t.get_name() for t in ai.tool_registry.get_all_tools()]
assert "memory" not in names
# 其余 orchestrator 管理面工具不受影响
assert "memo" in names
# ==================== handle_once_mode 路由 ====================
class TestHandleOnceModeRouting:
def test_no_once_returns_false(self):
"""未传 --once → 返回 False,不进入单次模式"""
manager = mock.MagicMock()
assert handle_once_mode(manager, _ns(once=None)) is False
# ==================== cli_callback 分流 ====================
class TestCliCallback分流:
"""通过捕获 sys.stdout / sys.stderr 验证分流(handle_once_mode 内部闭包,
用 subprocess 太重,这里直接测 DeepSeekChat + 回调契约)。"""
def _collect(self, ai, query, quiet=False, show_reasoning=False):
out, err = [], []
def cb(t, c):
if t == "chunk":
out.append(c)
elif t == "complete":
pass
elif t == "error":
err.append(c)
elif t == "line":
if not quiet:
err.append(c)
elif t in ("reasoning", "reasoning_start", "reasoning_end"):
if show_reasoning and not quiet:
err.append(c)
resp = ai.think_and_respond(query, cb)
return "".join(out), "".join(err), resp
def test_chunk_goes_to_stdout(self):
ai = _make_ai(events=[{"type": "content", "text": "你好世界"},
{"type": "done"}])
out, err, resp = self._collect(ai, "hi")
assert "你好世界" in out
def test_reasoning_hidden_by_default(self):
ai = _make_ai(events=[{"type": "reasoning", "text": "RSN_CONTENT"},
{"type": "content", "text": "答案"},
{"type": "done"}])
out, err, _ = self._collect(ai, "q", show_reasoning=False)
assert "RSN_CONTENT" not in err
def test_reasoning_shown_with_flag(self):
ai = _make_ai(events=[{"type": "reasoning", "text": "RSN_CONTENT"},
{"type": "content", "text": "答案"},
{"type": "done"}])
out, err, _ = self._collect(ai, "q", show_reasoning=True)
assert "RSN_CONTENT" in err
def test_reasoning_hidden_when_quiet(self):
ai = _make_ai(events=[{"type": "reasoning", "text": "RSN_CONTENT"},
{"type": "content", "text": "答案"},
{"type": "done"}])
out, err, _ = self._collect(ai, "q", quiet=True, show_reasoning=True)
assert "RSN_CONTENT" not in err
# ==================== 退出码相关状态 ====================
class TestExitCodeState:
def test_tool_failed_flag_default_false(self):
ai = _make_ai()
assert ai.stats["tool_failed"] is False
def test_tool_failed_flag_set_on_exception(self):
"""工具执行异常 → stats['tool_failed'] = True"""
ai = _make_ai()
# 模拟一个会抛异常的工具调用
ai.tool_registry.execute_tool = mock.MagicMock(side_effect=RuntimeError("boom"))
tool_calls = [{"id": "c1", "function": {"name": "memo", "arguments": "{}"}}]
ai._handle_tool_calls(tool_calls, None)
assert ai.stats["tool_failed"] is True
# ==================== 压缩提示不污染 stdout ====================
class TestCompressPromptNotOnStdout:
def test_compress_notice_goes_to_callback_line(self, monkeypatch, capsys):
"""触发压缩时,提示走 output_callback('line') 而非 print(stdout)。"""
ai = _make_ai()
# 强制压缩触发
monkeypatch.setattr(ai.compressor, "should_compress",
lambda msgs: (True, "测试触发"))
monkeypatch.setattr(ai.compressor, "compress_conversation",
lambda msgs: msgs)
monkeypatch.setattr(ai.compressor, "estimate_tokens", lambda msgs: 100)
lines = []
def cb(t, c):
if t == "line":
lines.append(c)
ai.think_and_respond("你好", cb)
captured = capsys.readouterr()
assert "📦" not in captured.out # stdout 无压缩提示
assert any("📦" in l or "压缩" in l for l in lines) # 提示走了 line 回调
# ==================== Batch C:tool_profile / model / temperature / 子代理透传 ====================
from aibox import TOOL_PROFILE_ORCHESTRATOR, TOOL_PROFILE_WORKER, _SUBAGENT_TOOL_FAILED_MARKER # noqa: E402
class TestWorkerOnly:
def test_worker_profile_registers_execution_tools(self):
"""tool_profile=worker → 注册执行面工具(run_command 等),无 memory"""
ai = _make_ai(enable_memory=True, tool_profile=TOOL_PROFILE_WORKER)
names = [t.get_name() for t in ai.tool_registry.get_all_tools()]
assert "run_command" in names
assert "read_file" in names
assert "memory" not in names # worker 无记忆工具
assert "create_subagent" not in names # worker 不能派生
def test_orchestrator_profile_registers_memory(self):
"""tool_profile=orchestrator → 注册管理面工具(memory/create_subagent)"""
ai = _make_ai(enable_memory=True)
ai.tool_profile = TOOL_PROFILE_ORCHESTRATOR
ai._register_tools()
names = [t.get_name() for t in ai.tool_registry.get_all_tools()]
assert "memory" in names
assert "create_subagent" in names
class TestModelTemperatureOverride:
def test_model_override(self):
"""model 参数透传到 create_llm_client"""
ai = _make_ai()
assert ai.client.model == "fake-model" # FakeLLMClient.model
# 注:真实链路 create_llm_client(model=...) 已在 _make_ai 中 mock 拦截
def test_temperature_override(self):
"""temperature 显式传入覆盖 constants 默认"""
ai = _make_ai()
# FakeLLMClient 不关心 temperature,这里验证属性被正确设置
ai.temperature = 0.1
assert ai.temperature == 0.1
class TestSubAgentToolFailurePropagation:
def test_subagent_tool_failed_marker_sets_stats(self):
"""create_subagent 返回带标记 → orchestrator stats['tool_failed']=True 且标记被剥离"""
ai = _make_ai()
ai.tool_registry.execute_tool = mock.MagicMock(
return_value=_SUBAGENT_TOOL_FAILED_MARKER + "子代理报告正文"
)
tool_calls = [{"id": "c1", "function": {"name": "create_subagent", "arguments": "{}"}}]
tool_msgs = ai._handle_tool_calls(tool_calls, None)
assert ai.stats["tool_failed"] is True
# 标记已从结果中剥离(LLM 不应看到内部标记)
assert tool_msgs and tool_msgs[0]["role"] == "tool"
assert "_SUBAGENT_TOOL_FAILED" not in tool_msgs[0]["content"]
assert "子代理报告正文" in tool_msgs[0]["content"]
+24 -1
View File
@@ -96,6 +96,16 @@ class ReadFileTool(FileToolBase):
# 限制max_lines上限,防止请求超大范围 # 限制max_lines上限,防止请求超大范围
max_lines = min(max_lines, 500) max_lines = min(max_lines, 500)
# L1(审计 2026-08-18):start/end_line 为 LLM 可控参数,此前未强转
# 直拼 sed 单引号模板——LLM 传含引号的字符串可闭合引号注入命令。
# 强转 int(非法输入在此拦截)并夹取合理范围。
try:
start_line = int(start_line)
end_line = int(end_line)
except (TypeError, ValueError):
return "❌ 参数错误: start_line/end_line 必须为整数"
start_line = max(1, start_line)
end_line = max(0, min(end_line, 10_000_000))
quoted = shlex.quote(abs_path) quoted = shlex.quote(abs_path)
# 构造等价的命令行读取命令 # 构造等价的命令行读取命令
@@ -175,8 +185,15 @@ class WriteFileTool(FileToolBase):
dir_cmd = f"mkdir -p {shlex.quote(directory)}" if directory else "" dir_cmd = f"mkdir -p {shlex.quote(directory)}" if directory else ""
# 内容写入使用 heredoc,避免引号/特殊字符转义问题 # 内容写入使用 heredoc,避免引号/特殊字符转义问题
# M1 修复(审计 2026-08-17):定界符唯一化——若 content 中含独立行
# 与定界符相同(如 "AIBOX_EOF"),heredoc 会提前终止,其后内容被
# 当作 shell 命令执行(注入)。冲突时递增后缀直到唯一。
delimiter = "AIBOX_EOF"
content_lines = {line.strip() for line in content.split("\n")}
while delimiter in content_lines:
delimiter += "_X"
redirect = ">" if mode == "w" else ">>" redirect = ">" if mode == "w" else ">>"
write_cmd = f"cat {redirect} {quoted} <<'AIBOX_EOF'\n{content}\nAIBOX_EOF" write_cmd = f"cat {redirect} {quoted} <<'{delimiter}'\n{content}\n{delimiter}"
if dir_cmd: if dir_cmd:
write_cmd = f"{dir_cmd} && {write_cmd}" write_cmd = f"{dir_cmd} && {write_cmd}"
@@ -313,6 +330,12 @@ class SimpleWebCrawlerTool(Tool):
if action not in ("get", "text", "title", "links", "search"): if action not in ("get", "text", "title", "links", "search"):
return f"❌ 未知操作: {action}" return f"❌ 未知操作: {action}"
# L10(审计 2026-08-18):url 为 LLM 可控输入(可被网页内容诱导),
# 此前不限 scheme——file:// 可经生成的 curl 指引读取本地文件(SSRF)。
# 白名单 http/https 后其余协议直接拒绝。
if not url or not str(url).strip().lower().startswith(("http://", "https://")):
return "❌ 仅支持 http/https 协议地址(已拦截 file:// 等非网络协议)"
quoted_url = shlex.quote(url) quoted_url = shlex.quote(url)
timeout = constants.WEB_TIMEOUT timeout = constants.WEB_TIMEOUT
user_agent = shlex.quote(constants.WEB_USER_AGENT) user_agent = shlex.quote(constants.WEB_USER_AGENT)
+12 -153
View File
@@ -5,14 +5,15 @@ tools.command_tool - 命令行执行工具
抽离自 aibox.py,使用抽象的 CommandExecutor 实现命令执行(流式 + 超时控制)。 抽离自 aibox.py,使用抽象的 CommandExecutor 实现命令执行(流式 + 超时控制)。
工具本身不持有 subprocess 逻辑,而是委托给 SubprocessCommandExecutor。 工具本身不持有 subprocess 逻辑,而是委托给 SubprocessCommandExecutor。
H1 修复(审计 2026-08-17):此前本类内置一套重复的 Popen 读取循环,
完全绕过注入的 executor,导致 1MB 输出上限、cancel() 三层中断、
超时机制三项保护同时失效。现改为纯委托,行为与 executor 完全一致。
依赖:common (ConfigManager, constants) + tools.base.Tool + subsystems.executors 依赖:common (ConfigManager, constants) + tools.base.Tool + subsystems.executors
不反向依赖 aibox 任何符号。 不反向依赖 aibox 任何符号。
""" """
import os
import queue import queue
import subprocess
import time
from typing import Dict from typing import Dict
from common import ConfigManager from common import ConfigManager
@@ -60,163 +61,21 @@ class CommandLineTool(Tool):
} }
def execute_streaming(self, output_queue: queue.Queue, **kwargs): def execute_streaming(self, output_queue: queue.Queue, **kwargs):
"""流式执行命令,支持超时控制""" """流式执行命令:纯委托给 executor(1MB 上限/超时/cancel 全部生效)"""
command = kwargs.get("command") command = kwargs.get("command")
timeout = kwargs.get("timeout", 30)
work_dir = kwargs.get("work_dir", ".")
extra_env = kwargs.get("env", {})
if not command: if not command:
output_queue.put(("line", "❌ 错误:缺少命令参数\n")) output_queue.put(("line", "❌ 错误:缺少命令参数\n"))
output_queue.put(("complete", "❌ 错误:缺少命令参数")) output_queue.put(("complete", "❌ 错误:缺少命令参数"))
return return
# 发送开始执行的消息 self.executor.execute_streaming(
output_queue.put(("line", f"\n🔧 执行命令: {command}\n")) command,
output_queue.put(("line", f"⏱️ 超时设置: {timeout} 秒\n")) output_queue,
if work_dir != ".": timeout=kwargs.get("timeout", 30),
output_queue.put(("line", f"📁 工作目录: {work_dir}\n")) work_dir=kwargs.get("work_dir", "."),
output_queue.put(("line", "-" * 50 + "\n")) env=kwargs.get("env"),
)
# 准备环境变量
env = os.environ.copy()
if extra_env:
env.update(extra_env)
try:
# 使用 subprocess.Popen 实现流式输出,支持超时
process = subprocess.Popen(
command,
shell=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
stdin=subprocess.PIPE,
text=True,
bufsize=1,
universal_newlines=True,
env=env,
cwd=os.path.expanduser(work_dir)
)
start_time = time.time()
output_lines = []
timeout_occurred = False
# 实时读取输出
while True:
# 检查超时
elapsed = time.time() - start_time
if timeout > 0 and elapsed > timeout:
process.terminate()
timeout_occurred = True
output_queue.put(("line", f"\n⏰ 命令执行超时({timeout}秒),已终止\n"))
break
# 使用非阻塞方式读取一行
import select
import sys
# 检查是否有输出可读
if sys.platform == 'win32':
# Windows 下使用 readline 带超时
try:
import msvcrt
if msvcrt.kbhit():
pass
line = process.stdout.readline()
if not line and process.poll() is not None:
break
if line:
line = line.rstrip('\n')
output_queue.put(("line", line + "\n"))
output_lines.append(line)
else:
time.sleep(0.05)
except:
line = process.stdout.readline()
if not line and process.poll() is not None:
break
if line:
line = line.rstrip('\n')
output_queue.put(("line", line + "\n"))
output_lines.append(line)
else:
# Unix 系统可以使用 select
import select
rlist, _, _ = select.select([process.stdout], [], [], 0.5)
if rlist:
line = process.stdout.readline()
if not line and process.poll() is not None:
break
if line:
line = line.rstrip('\n')
output_queue.put(("line", line + "\n"))
output_lines.append(line)
else:
# 检查进程是否结束
if process.poll() is not None:
# 读取剩余输出
remaining = process.stdout.read()
if remaining:
remaining = remaining.rstrip('\n')
if remaining:
output_queue.put(("line", remaining + "\n"))
output_lines.append(remaining)
break
# 如果超时,确保进程被终止
if timeout_occurred and process.poll() is None:
process.kill()
process.wait(timeout=2)
# 获取返回码
return_code = process.returncode if process.returncode is not None else -1
output_queue.put(("line", "-" * 50 + "\n"))
if timeout_occurred:
output_queue.put(("line", f"⏰ 命令执行超时({timeout}秒),返回码: {return_code}\n"))
else:
output_queue.put(("line", f"✅ 命令执行完成,返回码: {return_code}\n"))
# 准备完整结果
full_output = "\n".join(output_lines)
data = {
"输出": full_output if full_output else "(无输出)",
"返回码": return_code
}
if timeout_occurred:
result = self.format_result(False, f"命令执行超时({timeout}秒)", data)
else:
result = self.format_result(return_code == 0, "命令执行完成", data)
output_queue.put(("complete", result))
except subprocess.TimeoutExpired:
error_msg = f"❌ 命令执行超时({timeout}秒)"
output_queue.put(("line", error_msg + "\n"))
output_queue.put(("complete", error_msg))
except FileNotFoundError as e:
error_msg = f"❌ 命令执行错误: 命令未找到 - {str(e)}"
output_queue.put(("line", error_msg + "\n"))
output_queue.put(("complete", error_msg))
except PermissionError as e:
error_msg = f"❌ 命令执行错误: 权限不足 - {str(e)}"
output_queue.put(("line", error_msg + "\n"))
output_queue.put(("complete", error_msg))
except Exception as e:
error_msg = f"❌ 执行错误: {str(e)}"
output_queue.put(("line", error_msg + "\n"))
output_queue.put(("complete", error_msg))
finally:
# 确保进程被清理
if 'process' in locals() and process and process.poll() is None:
try:
process.terminate()
process.wait(timeout=2)
except:
process.kill()
def execute(self, command: str, timeout: int = 30, def execute(self, command: str, timeout: int = 30,
work_dir: str = ".", env: Dict = None, **kwargs) -> str: work_dir: str = ".", env: Dict = None, **kwargs) -> str:
+3 -1
View File
@@ -752,7 +752,9 @@ class MemoTool(Tool):
memo_id = kwargs.get("memo_id") memo_id = kwargs.get("memo_id")
if memo_id: if memo_id:
if self.memo_db.complete_memo(memo_id, force_complete=True): # M5:真实物理删除(此前调 complete_memo 只标记完成,行不删除,
# 重复任务被推进而非移除,stats 计数失真)
if self.memo_db.delete_memo(memo_id):
return self.format_result(True, f"任务 #{memo_id} 已删除") return self.format_result(True, f"任务 #{memo_id} 已删除")
else: else:
return f"❌ 无法删除任务 #{memo_id}" return f"❌ 无法删除任务 #{memo_id}"
+5 -2
View File
@@ -39,10 +39,13 @@ class AskUserTool(Tool):
) )
} }
}, },
"required": [] # L8:此前 required 为空,LLM 不传 question 时 str + None 抛 TypeError
"required": ["question"]
} }
def execute(self, question: str, **kwargs) -> str: def execute(self, question: str = None, **kwargs) -> str:
if not question:
return "❌ 需要提供问题内容(question 参数)"
return ( return (
"你遇到了想搞清楚的事情: " + question + "\n你现在可以询问用户。" "你遇到了想搞清楚的事情: " + question + "\n你现在可以询问用户。"
) )
+15
View File
@@ -56,6 +56,11 @@ class UpdateIdentityTool(Tool):
if not content: if not content:
return "❌ 需要提供身份信息内容" return "❌ 需要提供身份信息内容"
# N9:持久化注入面约束——内容超限拒绝写入(管理端另有兜底)
if len(content) > PromptManager.MAX_CONTENT_LENGTH:
return (f"❌ 内容过长({len(content)} > {PromptManager.MAX_CONTENT_LENGTH} 字符),"
f"身份文件作为系统提示词加载,请精简后重试")
if not confirm: if not confirm:
return "❌ 身份更新已取消,如需更新请设置 confirm=true" return "❌ 身份更新已取消,如需更新请设置 confirm=true"
@@ -119,6 +124,11 @@ class UpdateSoulTool(Tool):
if not content: if not content:
return "❌ 需要提供智能体自我认识定义内容" return "❌ 需要提供智能体自我认识定义内容"
# N9:持久化注入面约束——内容超限拒绝写入(管理端另有兜底)
if len(content) > PromptManager.MAX_CONTENT_LENGTH:
return (f"❌ 内容过长({len(content)} > {PromptManager.MAX_CONTENT_LENGTH} 字符),"
f"灵魂文件作为系统提示词加载,请精简后重试")
if not confirm: if not confirm:
return "❌ 自我认识更新已取消,如需更新请设置 confirm=true" return "❌ 自我认识更新已取消,如需更新请设置 confirm=true"
@@ -223,6 +233,11 @@ class SaveMemoryAndEndConversationTool(Tool):
status_content += f"【下一步计划】\n{next_goals}\n\n" status_content += f"【下一步计划】\n{next_goals}\n\n"
status_content += f"【对话总结】\n{summary}" status_content += f"【对话总结】\n{summary}"
# N9:状态文件作为系统提示词加载,超长拒绝写入(约束注入规模)
if len(status_content) > PromptManager.MAX_CONTENT_LENGTH:
return (f"❌ 状态内容过长({len(status_content)} > "
f"{PromptManager.MAX_CONTENT_LENGTH} 字符),请精简 current_status/summary 后重试")
# 更新状态文件 # 更新状态文件
status_updated = self.prompt_manager.update_status(status_content) status_updated = self.prompt_manager.update_status(status_content)
+121 -7
View File
@@ -3,18 +3,26 @@
tools.proxy_tools - 代理配置工具 tools.proxy_tools - 代理配置工具
抽离自 aibox.py,包含 2 个工具: 抽离自 aibox.py,包含 2 个工具:
- SetProxyTool : 设置 SOCKS5 代理 - SetProxyTool : 设置 SOCKS5 代理(设置前验证连通性)
- GetProxyTool : 查看当前代理配置 - GetProxyTool : 查看当前代理配置
依赖:common (ConfigManager) + tools.base.Tool 依赖:common (ConfigManager) + tools.base.Tool
不反向依赖 aibox 任何符号。 不反向依赖 aibox 任何符号。
""" """
from typing import Dict import os
from typing import Dict, Optional, Tuple
import requests
from common import ConfigManager from common import ConfigManager
from .base import Tool from .base import Tool
# 探测端点:无内容的轻量响应,验证完整 SOCKS5 握手(含认证)与出网连通性
_PROBE_URL = "http://www.gstatic.com/generate_204"
# (连接超时, 读超时):验证总耗时上限约 10s,避免阻塞工具调用过久
_PROBE_TIMEOUT = (4, 6)
class SetProxyTool(Tool): class SetProxyTool(Tool):
"""设置 SOCKS5 代理的工具""" """设置 SOCKS5 代理的工具"""
@@ -25,6 +33,8 @@ class SetProxyTool(Tool):
def get_description(self) -> str: def get_description(self) -> str:
return """设置智能体的 SOCKS5 代理配置。 return """设置智能体的 SOCKS5 代理配置。
调用后所有 HTTP/HTTPS 请求(包括网页访问、API 调用)都会通过该代理。 调用后所有 HTTP/HTTPS 请求(包括网页访问、API 调用)都会通过该代理。
启用代理时会先验证连通性(完整 SOCKS5 握手 + 探测请求),验证失败不保存配置;
代理暂未启动等场景可用 skip_check=true 跳过验证强制保存。
可随时修改,即时生效。""" 可随时修改,即时生效。"""
def get_parameters(self) -> Dict: def get_parameters(self) -> Dict:
@@ -34,24 +44,128 @@ class SetProxyTool(Tool):
"host": {"type": "string", "description": "代理服务器地址,如 127.0.0.1"}, "host": {"type": "string", "description": "代理服务器地址,如 127.0.0.1"},
"port": {"type": "integer", "description": "代理端口,如 1080"}, "port": {"type": "integer", "description": "代理端口,如 1080"},
"username": {"type": "string", "description": "用户名(可选)"}, "username": {"type": "string", "description": "用户名(可选)"},
"password": {"type": "string", "description": "密码(可选)"}, "password": {"type": "string", "description": "密码(可选,明文仅本次会话有效,不落盘)"},
"password_env": {"type": "string", "description": "存有密码的环境变量名(可选,跨重启持久的推荐方式)"},
"enabled": {"type": "boolean", "description": "是否启用代理,默认 true"}, "enabled": {"type": "boolean", "description": "是否启用代理,默认 true"},
"save": {"type": "boolean", "description": "是否保存到配置文件(持久化),默认 true"} "save": {"type": "boolean", "description": "是否保存到配置文件(持久化),默认 true"},
"skip_check": {"type": "boolean", "description": "跳过连通性验证强制保存(默认 false)", "default": False}
}, },
"required": ["host", "port"] "required": ["host", "port"]
} }
@staticmethod
def _resolve_password(password: Optional[str], password_env: Optional[str]) -> Optional[str]:
"""对齐 ConfigManager._resolve_proxy_password:明文优先,否则查环境变量"""
if password:
return password
if password_env:
return os.environ.get(password_env) or None
return None
def _check_connectivity(self, host: str, port: int,
username: Optional[str], password: Optional[str]) -> Tuple[bool, str, str]:
"""验证代理连通性:走一次完整 SOCKS5 握手 + 探测请求。
只做 TCP connect 验不了用户名/密码,必须发真实请求。
返回 (ok, error_kind, detail);error_kind 取值:
auth_failed / timeout / connect_failed / probe_failed / error
"""
if username and password:
proxy_url = f"socks5://{username}:{password}@{host}:{port}"
else:
proxy_url = f"socks5://{host}:{port}"
try:
# trust_env=False:排除系统环境变量代理(http_proxy 等)干扰,只验证目标代理
session = requests.Session()
session.trust_env = False
resp = session.get(
_PROBE_URL,
proxies={"http": proxy_url, "https": proxy_url},
timeout=_PROBE_TIMEOUT,
)
# generate_204 正常返回 204;部分代理/目标返回 200 也视为连通
if resp.status_code in (200, 204):
return True, "", f"探测成功 HTTP {resp.status_code}"
return False, "probe_failed", f"探测端点返回 HTTP {resp.status_code}"
except requests.exceptions.Timeout as e:
# 含 ConnectTimeout(连接阶段超时):异常类型与系统语言无关,优先于消息匹配
return False, "timeout", str(e)
except requests.exceptions.ConnectionError as e:
# 连接层失败(拒绝/不可达/中断)。PySocks 依赖缺失与认证失败
# 也会以 ConnectionError 形态冒出,按消息细分。
msg = str(e)
low = msg.lower()
if "missing dependencies for socks" in low:
return False, "missing_dependency", msg
if "authentication" in low or "auth" in low:
return False, "auth_failed", msg
return False, "connect_failed", msg
except Exception as e:
# 兜底:仅按库自身(非本地化)消息关键词分类
msg = str(e)
low = msg.lower()
if "missing dependencies for socks" in low:
return False, "missing_dependency", msg
if "authentication" in low or "auth" in low:
return False, "auth_failed", msg
if "timed out" in low or "timeout" in low:
return False, "timeout", msg
if "refused" in low or "unreachable" in low or "closed" in low:
return False, "connect_failed", msg
return False, "error", msg
def execute(self, host: str, port: int, username: str = None, password: str = None, def execute(self, host: str, port: int, username: str = None, password: str = None,
enabled: bool = True, save: bool = True, **kwargs) -> str: password_env: str = None, enabled: bool = True, save: bool = True,
skip_check: bool = False, **kwargs) -> str:
# 设置前连通性验证:坏地址/凭据在入口一次性暴露,避免静默落盘后在
# 后续 web/API 调用中离场景报错、LLM 难以归因。skip_check 留给
# "代理暂未启动但先写配置"的场景;禁用代理无需验证。
check_detail = ""
if enabled and not skip_check:
pw = self._resolve_password(password, password_env)
# 指定了 password_env 但环境变量为空:认证凭据缺失,提前给出精准提示
if username and not password and password_env and pw is None:
return self.format_result(False, "代理验证失败,未保存配置", {
"错误类型": "环境变量为空",
"详情": f"环境变量 {password_env} 未设置或为空",
"提示": "请先设置该环境变量,或改用 password 参数(仅本次会话有效)",
})
ok, kind, detail = self._check_connectivity(host, port, username, pw)
if not ok:
hints = {
"auth_failed": "用户名/密码错误,请核对凭据",
"timeout": "代理不可达(超时),请检查地址与网络",
"connect_failed": "连接被拒绝或不可达,请检查 host/port",
"missing_dependency": "缺少 SOCKS 支持库,请安装:pip install pysocks",
}
return self.format_result(False, "代理不可达,未保存配置", {
"错误类型": kind,
"详情": detail[:300],
"提示": hints.get(kind, "如确认配置无误可设置 skip_check=true 跳过验证"),
})
check_detail = detail
config = ConfigManager() config = ConfigManager()
config.set_proxy(host, port, username, password, enabled, save) saved = config.set_proxy(host, port, username, password, password_env, enabled, save)
proxy_info = config.get_proxy_info() proxy_info = config.get_proxy_info()
if proxy_info.get("password_env"):
pw_source = "环境变量 " + proxy_info["password_env"]
elif proxy_info.get("password"):
pw_source = "仅本次会话(不落盘)"
else:
pw_source = "无"
data = { data = {
"代理状态": "启用" if proxy_info["enabled"] else "禁用", "代理状态": "启用" if proxy_info["enabled"] else "禁用",
"服务器": f"{proxy_info['host']}:{proxy_info['port']}", "服务器": f"{proxy_info['host']}:{proxy_info['port']}",
"认证": "有" if proxy_info.get("username") else "无", "认证": "有" if proxy_info.get("username") else "无",
"持久化": "已保存" if save else "仅内存" "密码来源": pw_source,
"持久化": "已保存" if saved else "保存失败(仅本次会话有效,重启后丢失)"
} }
if check_detail:
data["连通性"] = check_detail
# L2:落盘失败(磁盘只读等)如实报告,不再静默报"已保存"
if save and not saved:
return self.format_result(False, "代理已配置但保存失败(仅本次会话有效,重启后丢失)", data)
return self.format_result(True, "代理设置成功", data) return self.format_result(True, "代理设置成功", data)
+72 -11
View File
@@ -11,10 +11,26 @@ import threading
import time import time
from typing import Dict, List, Optional, Tuple from typing import Dict, List, Optional, Tuple
from common import Logger, i18n from common import Logger, constants, i18n
from .base import Tool from .base import Tool
# 工具参数名命中这些关键词时,debug 日志里打码(防密码/密钥进日志文件)
# L11(审计 2026-08-18):精确匹配漏掉 apiKey/access_token/passwd 等变体,
# 改包含匹配——日志场景下过度打码可接受,漏打码才是问题。
_SENSITIVE_PARAM_KEYS = (
"password", "passwd", "api_key", "apikey", "token", "secret",
"authorization", "credential",
)
def _sanitize_kwargs(kwargs: Dict) -> Dict:
"""复制一份参数并把敏感键的值替换为 ***(仅用于日志输出)"""
return {
k: ("***" if any(s in str(k).lower() for s in _SENSITIVE_PARAM_KEYS) else v)
for k, v in kwargs.items()
}
class ToolRegistry: class ToolRegistry:
"""工具注册器 - 支持技能热重载""" """工具注册器 - 支持技能热重载"""
@@ -25,6 +41,8 @@ class ToolRegistry:
self.logger = logger or Logger("ToolRegistry") self.logger = logger or Logger("ToolRegistry")
self.current_output_queue = None # 用于流式输出的队列 self.current_output_queue = None # 用于流式输出的队列
self.skill_manager: Optional['SkillManager'] = None # 技能管理器引用 self.skill_manager: Optional['SkillManager'] = None # 技能管理器引用
# 单工具流式执行总上限(工具自身秒级超时之外的兜底;测试可调小)
self.stream_tool_timeout = 3600
def register(self, tool: Tool, is_builtin: bool = False): def register(self, tool: Tool, is_builtin: bool = False):
"""注册工具,可以标记是否为内置工具""" """注册工具,可以标记是否为内置工具"""
@@ -63,7 +81,7 @@ class ToolRegistry:
if not tool: if not tool:
return f"❌ 错误:找不到工具 '{name}'" return f"❌ 错误:找不到工具 '{name}'"
self.logger.debug(f"工具调用: {name}({kwargs})") self.logger.debug(f"工具调用: {name}({_sanitize_kwargs(kwargs)})")
start_time = time.time() start_time = time.time()
try: try:
@@ -84,16 +102,52 @@ class ToolRegistry:
) )
thread.daemon = True thread.daemon = True
thread.start() thread.start()
# 从队列收集输出并发送到当前队列 # M2 修复(审计 2026-08-17):转发循环加总超时。
# 此前 while thread.is_alive() 在工具内部卡死(如子进程
# 等待 stdin)时永不退出;超时后放弃等待,避免本调用
# 线程被连带挂起。
# N6 修复(审计 2026-08-18):超时路径此前直接 return,
# 跳过下方清理——daemon 线程继续持有 tool.output_queue
# 实例属性,属性被后续调用覆盖到新会话队列后,僵尸输出
# 会混入后续对话流。现在统一在出口清理。
tool_timeout = self.stream_tool_timeout
deadline = time.time() + tool_timeout
final_result = None final_result = None
timed_out = False
# N8:流式转发同样受 TOOL_MAX_RESULT_LENGTH 约束——此前仅非流式
# 返回值受限,超长内容(如记忆全文)经流式通道可完整推到前端
line_budget = constants.TOOL_MAX_RESULT_LENGTH
forwarded = 0
truncation_noted = False
while thread.is_alive() or not output_queue.empty(): while thread.is_alive() or not output_queue.empty():
if time.time() > deadline:
self.logger.warning(
f"工具 {name} 流式执行超过 {tool_timeout}s 总上限,放弃等待")
timed_out = True
break
try: try:
msg_type, content = output_queue.get(timeout=0.1) msg_type, content = output_queue.get(timeout=0.1)
if msg_type == "line": if msg_type == "line":
piece = str(content) if content is not None else ""
if forwarded >= line_budget:
# 预算耗尽:停止转发,仅提示一次
if not truncation_noted:
truncation_noted = True
if self.current_output_queue:
self.current_output_queue.put(
("line", f"\n⚠️ 流式输出超过 {line_budget} 字符上限,已停止转发剩余内容\n"))
continue
remaining = line_budget - forwarded
if len(piece) > remaining:
piece = piece[:remaining] + \
f"\n⚠️ 流式输出超过 {line_budget} 字符上限,已截断"
forwarded = line_budget
else:
forwarded += len(piece)
# 发送到当前队列(会通过WebSocket发送) # 发送到当前队列(会通过WebSocket发送)
if self.current_output_queue: if self.current_output_queue:
self.current_output_queue.put(("line", content)) self.current_output_queue.put(("line", piece))
elif msg_type == "complete": elif msg_type == "complete":
final_result = content final_result = content
# 也发送完整结果 # 也发送完整结果
@@ -101,14 +155,21 @@ class ToolRegistry:
self.current_output_queue.put(("complete", content)) self.current_output_queue.put(("complete", content))
except queue.Empty: except queue.Empty:
continue continue
thread.join(timeout=5) if not timed_out:
if thread.is_alive(): thread.join(timeout=5)
return "❌ 工具执行超时" if thread.is_alive():
timed_out = True
result = final_result or ""
# 统一清理(正常与超时路径都必须执行):断开实例属性,
# 僵尸 daemon 线程的 send_line 落到 None 上不再外写
tool.streaming_output = False tool.streaming_output = False
tool.output_queue = None tool.output_queue = None
if timed_out:
result = f"❌ 工具执行超时(>{tool_timeout}s,已放弃等待)"
else:
result = final_result or ""
else: else:
# 普通执行 # 普通执行
result = tool.execute(**kwargs) result = tool.execute(**kwargs)
+8 -3
View File
@@ -16,6 +16,7 @@ tools.skill_tools - 技能相关工具
import os import os
import queue import queue
import re import re
import shlex
from typing import Dict, Optional from typing import Dict, Optional
from common import constants, i18n, Logger from common import constants, i18n, Logger
@@ -253,10 +254,14 @@ class InstallSkillTool(Tool):
skills_dir = constants.SKILLS_DIR skills_dir = constants.SKILLS_DIR
# 如果给了具体路径,在说明里带入,否则用占位符 # 如果给了具体路径,在说明里带入,否则用占位符
src = skill_path.strip() if skill_path else "/path/to/your_skill.md" raw_src = skill_path.strip() if skill_path else ""
# 去掉扩展名作为技能名称,再统一补 .md 作为目标文件名 # 去掉扩展名作为技能名称,再统一补 .md 作为目标文件名
base = os.path.splitext(os.path.basename(src))[0] if src else "your_skill" base = os.path.splitext(os.path.basename(raw_src))[0] if raw_src else "your_skill"
dst = os.path.join(skills_dir, base + ".md") dst = os.path.join(skills_dir, base + ".md")
# L7(审计 2026-08-18):src 为 LLM 可控输入,含空格/元字符时
# 未经引用的命令会被 shell 拆词/注入(指引由子代理经 run_command 执行)
src = shlex.quote(raw_src) if raw_src else "/path/to/your_skill.md"
dst_q = shlex.quote(dst)
guide = f"""📦 扩展技能安装指引 guide = f"""📦 扩展技能安装指引
{"=" * 55} {"=" * 55}
@@ -282,7 +287,7 @@ class InstallSkillTool(Tool):
调用 create_subagent 派生子代理执行复制命令(目标文件名统一补 .md 扩展名): 调用 create_subagent 派生子代理执行复制命令(目标文件名统一补 .md 扩展名):
cp {src} {dst} cp {src} {dst_q}
⚠️ 注意:目标文件名必须带 .md 扩展名, ⚠️ 注意:目标文件名必须带 .md 扩展名,
系统以 .md 文件作为技能加载,且技能名称取文件名(不含 .md)。 系统以 .md 文件作为技能加载,且技能名称取文件名(不含 .md)。
+53
View File
@@ -0,0 +1,53 @@
# jump.py —— jump.mt 的 mtc DSL 源码(新格式)
# 跳跃:四腿 1 号关节(s2/s4/s6/s8)快速下蹲 10°→20°→30°(各 20ms)后 200ms 弹回站立。
# 编译:cd mtc && python -m mtc ../bot4cj/scripts/jump.py -o ../bot4cj/scripts/jump.mt
Alias("A", "angle")
Alias("F", "frequency")
I2C("i2c-0", "/dev/i2c-0")
I2C("i2c-1", "/dev/i2c-1")
PCA9685("pca9685-0", "i2c-1", "0x40")
# 姿态传感器按需启用:
# MPU6050_BIND("mpu6050-0", "i2c-1", "0x69")
# MPU6050_BIND("mpu6050-1", "i2c-1", "0x68")
# MPU6050_BIND("mpu6050-2", "i2c-0", "0x68")
# MPU6050_BIND("mpu6050-3", "i2c-0", "0x69")
Speaker("spk", 2, 48000, 0.6, 5000, 0)
# 名 max ch
_SERVOS = [("s1", 90, 0), # rb0
("s2", 180, 1), # rb1
("s3", -90, 2), # lb0
("s4", -180, 3), # lb1
("s5", 90, 4), # lf0
("s6", 180, 5), # lf1
("s7", -90, 6), # rf0
("s8", -180, 7), # rf1
("s9", 180, 8), # cam0
("s10", 180, 9)] # cam1
for _name, _max, _ch in _SERVOS:
Servo(_name, _max, 0, 0, "pca9685-0", _ch)
# 录制源(pybot --record 需要 @Camera 块)
Camera("cam0", 640, 480, 30, 8)
_STAND = [45, 0, 45, 0, 45, 0, 45, 0, 90, 90]
def stand(dur=10):
for _i, _ang in enumerate(_STAND, 1):
SetTo(f"s{_i}", _ang, dur)
# ---- 动作序列(200 + 3×20 + 200,T=460ms) ----
stand()
Beep(10, 460) # spk.F=10 贯穿全程
Delay(200)
for _ang in (10, 20, 30):
for _dev in ("s2", "s4", "s6", "s8"):
SetTo(_dev, _ang, 20)
Delay(20)
stand(200) # 弹回站立
Delay(200)
+57
View File
@@ -0,0 +1,57 @@
# left-hand.py —— left-hand.mt 的 mtc DSL 源码(新格式)
# 左手:s1/s8 展开维持 1s 后收回;蜂鸣 10→400→1000→10 Hz 阶跃。
# 编译:cd mtc && python -m mtc ../bot4cj/scripts/left-hand.py -o ../bot4cj/scripts/left-hand.mt
Alias("A", "angle")
Alias("F", "frequency")
I2C("i2c-0", "/dev/i2c-0")
I2C("i2c-1", "/dev/i2c-1")
PCA9685("pca9685-0", "i2c-1", "0x40")
# 姿态传感器按需启用:
# MPU6050_BIND("mpu6050-0", "i2c-1", "0x69")
# MPU6050_BIND("mpu6050-1", "i2c-1", "0x68")
# MPU6050_BIND("mpu6050-2", "i2c-0", "0x68")
# MPU6050_BIND("mpu6050-3", "i2c-0", "0x69")
Speaker("spk", 2, 48000, 0.6, 5000, 0)
# 名 max ch
_SERVOS = [("s1", 90, 0), # rb0
("s2", 180, 1), # rb1
("s3", -90, 2), # lb0
("s4", -180, 3), # lb1
("s5", 90, 4), # lf0
("s6", 180, 5), # lf1
("s7", -90, 6), # rf0
("s8", -180, 7), # rf1
("s9", 180, 8), # cam0
("s10", 180, 9)] # cam1
for _name, _max, _ch in _SERVOS:
Servo(_name, _max, 0, 0, "pca9685-0", _ch)
# 录制源(pybot --record 需要 @Camera 块)
Camera("cam0", 640, 480, 30, 8)
_STAND = [45, 0, 45, 0, 45, 0, 45, 0, 90, 90]
def stand(dur=10):
for _i, _ang in enumerate(_STAND, 1):
SetTo(f"s{_i}", _ang, dur)
# ---- 动作序列(200 + 200 + 1000 + 20,T=1420ms) ----
stand()
Beep(10, 200) # r1: F=10
Delay(200)
Beep(400, 200) # r2: 仅变频率(原行无舵机变化)
Delay(200)
SetTo("s1", 0, 1000) # r3: s1 45→0,s8 0→150
SetTo("s8", 150, 1000)
Beep(1000, 1000) # r3: F=1000
Delay(1000)
SetTo("s1", 45, 20) # r4: 收回
SetTo("s8", 0, 20)
Beep(10, 20) # r4: F=10
Delay(20)
+71
View File
@@ -0,0 +1,71 @@
# left-turn.py —— left-turn.mt 的 mtc DSL 源码(新格式)
# 左转步态:对角腿轮换抬摆(s2/s6 ↔ s4/s8 为一组,s1/s5、s3/s7 配合)。
# 编译:cd mtc && python -m mtc ../bot4cj/scripts/left-turn.py -o ../bot4cj/scripts/left-turn.mt
Alias("A", "angle")
Alias("F", "frequency")
I2C("i2c-0", "/dev/i2c-0")
I2C("i2c-1", "/dev/i2c-1")
PCA9685("pca9685-0", "i2c-1", "0x40")
# 姿态传感器按需启用:
# MPU6050_BIND("mpu6050-0", "i2c-1", "0x69")
# MPU6050_BIND("mpu6050-1", "i2c-1", "0x68")
# MPU6050_BIND("mpu6050-2", "i2c-0", "0x68")
# MPU6050_BIND("mpu6050-3", "i2c-0", "0x69")
Speaker("spk", 2, 48000, 0.6, 5000, 0)
# 名 max ch
_SERVOS = [("s1", 90, 0), # rb0
("s2", 180, 1), # rb1
("s3", -90, 2), # lb0
("s4", -180, 3), # lb1
("s5", 90, 4), # lf0
("s6", 180, 5), # lf1
("s7", -90, 6), # rf0
("s8", -180, 7), # rf1
("s9", 180, 8), # cam0
("s10", 180, 9)] # cam1
for _name, _max, _ch in _SERVOS:
Servo(_name, _max, 0, 0, "pca9685-0", _ch)
# 录制源(pybot --record 需要 @Camera 块)
Camera("cam0", 640, 480, 30, 8)
_STAND = [45, 0, 45, 0, 45, 0, 45, 0, 90, 90]
def stand(dur=10):
for _i, _ang in enumerate(_STAND, 1):
SetTo(f"s{_i}", _ang, dur)
def legs(dev_a, dev_b, ang, dur):
SetTo(dev_a, ang, dur)
SetTo(dev_b, ang, dur)
# ---- 动作序列(200+100+50+50+100+10+50+50+100+10+50,T=770ms) ----
stand()
Beep(10, 770) # spk.F=10 贯穿全程
Delay(200)
legs("s2", "s6", 45, 100) # r2
Delay(100)
legs("s1", "s5", 90, 50) # r3
Delay(50)
legs("s2", "s6", 0, 50) # r4
Delay(50)
legs("s4", "s8", 45, 100) # r5
Delay(100)
legs("s1", "s5", 45, 10) # r6
Delay(10)
legs("s3", "s7", 0, 50) # r7
Delay(50)
legs("s4", "s8", 0, 50) # r8
Delay(50)
legs("s2", "s6", 45, 100) # r9
Delay(100)
legs("s3", "s7", 45, 10) # r10
Delay(10)
legs("s2", "s6", 0, 50) # r11
Delay(50)
+51
View File
@@ -0,0 +1,51 @@
# lie-down.py —— lie-down.mt 的 mtc DSL 源码(新格式)
# 趴下:四腿 1 号关节(s2/s4/s6/s8)10°→15°→20°→25°→30° 渐增(各 20ms)。
# 编译:cd mtc && python -m mtc ../bot4cj/scripts/lie-down.py -o ../bot4cj/scripts/lie-down.mt
Alias("A", "angle")
Alias("F", "frequency")
I2C("i2c-0", "/dev/i2c-0")
I2C("i2c-1", "/dev/i2c-1")
PCA9685("pca9685-0", "i2c-1", "0x40")
# 姿态传感器按需启用:
# MPU6050_BIND("mpu6050-0", "i2c-1", "0x69")
# MPU6050_BIND("mpu6050-1", "i2c-1", "0x68")
# MPU6050_BIND("mpu6050-2", "i2c-0", "0x68")
# MPU6050_BIND("mpu6050-3", "i2c-0", "0x69")
Speaker("spk", 2, 48000, 0.6, 5000, 0)
# 名 max ch
_SERVOS = [("s1", 90, 0), # rb0
("s2", 180, 1), # rb1
("s3", -90, 2), # lb0
("s4", -180, 3), # lb1
("s5", 90, 4), # lf0
("s6", 180, 5), # lf1
("s7", -90, 6), # rf0
("s8", -180, 7), # rf1
("s9", 180, 8), # cam0
("s10", 180, 9)] # cam1
for _name, _max, _ch in _SERVOS:
Servo(_name, _max, 0, 0, "pca9685-0", _ch)
# 录制源(pybot --record 需要 @Camera 块)
Camera("cam0", 640, 480, 30, 8)
_STAND = [45, 0, 45, 0, 45, 0, 45, 0, 90, 90]
def stand(dur=10):
for _i, _ang in enumerate(_STAND, 1):
SetTo(f"s{_i}", _ang, dur)
# ---- 动作序列(200 + 5×20,T=300ms) ----
stand()
Beep(10, 300)
Delay(200)
for _ang in (10, 15, 20, 25, 30):
for _dev in ("s2", "s4", "s6", "s8"):
SetTo(_dev, _ang, 20)
Delay(20)
+56
View File
@@ -0,0 +1,56 @@
# 录制用脚本:stand → 多姿态序列(微动作分解 + 合成帧 + IMU 占位)
# 首行不分解(1 步),后续 4 行 delay=200 → 各 20 微步,共 81 步
@Alias
name alias;
A angle;
F frequency;
@
@I2C
name path;
i2c-0 /dev/i2c-0;
i2c-1 /dev/i2c-1;
@
@PCA9685
name I2C address;
pca9685-0 i2c-1 0x40;
@
@MPU6050_BIND
name I2C addr;
mpu6050-0 i2c-1 0x68;
@
@Speaker
name channels sample_rate volumn max_f min_f;
spk 2 48000 0.6 5000 -;
@
@Servo
name max min offset controller channel;
s1 90 - - pca9685-0 0; # rb0
s2 180 - - - 1; # rb1
s3 -90 - - - 2; # lb0
s4 -180 - - - 3; # lb1
s5 90 - - - 4; # lf0
s6 180 - - - 5; # lf1
s7 -90 - - - 6; # rf0
s8 -180 - - - 7; # rf1
s9 180 - - - 8; # cam0
s10 180 - - - 9; # cam1
@
@Camera
name width height framerate capacity;
cam0 640 480 30 8;
@
@Action
s1.A s2.A s3.A s4.A s5.A s6.A s7.A s8.A s9.A s10.A spk.F spk.delay;
45 - 45 - 45 - 45 - 90 90 10 200;
60 - 60 - 60 - 60 - 90 90 - 200;
45 - 45 - 45 - 45 - 90 90 - 200;
30 - 30 - 30 - 30 - 90 90 - 200;
45 - 45 - 45 - 45 - 90 90 - 200;
@
+49
View File
@@ -0,0 +1,49 @@
# record_stand.py —— record_stand.mt 的 mtc DSL 源码(新格式)
# 录制用:stand → s1/s3/s5/s7 依次到 60°/45°/30°/45° 姿态序列(各 200ms),
# 含 @Camera(合成帧)与激活的 @MPU6050_BIND(imu.npy 录制)。
# 编译:cd mtc && python -m mtc ../bot4cj/scripts/record_stand.py -o ../bot4cj/scripts/record_stand.mt
Alias("A", "angle")
Alias("F", "frequency")
I2C("i2c-0", "/dev/i2c-0")
I2C("i2c-1", "/dev/i2c-1")
PCA9685("pca9685-0", "i2c-1", "0x40")
MPU6050_BIND("mpu6050-0", "i2c-1", "0x68")
Speaker("spk", 2, 48000, 0.6, 5000, 0) # min_f 原为占位符(首行解析为 0)
# 名 max ch
_SERVOS = [("s1", 90, 0), # rb0
("s2", 180, 1), # rb1
("s3", -90, 2), # lb0
("s4", -180, 3), # lb1
("s5", 90, 4), # lf0
("s6", 180, 5), # lf1
("s7", -90, 6), # rf0
("s8", -180, 7), # rf1
("s9", 180, 8), # cam0
("s10", 180, 9)] # cam1
for _name, _max, _ch in _SERVOS:
Servo(_name, _max, 0, 0, "pca9685-0", _ch)
Camera("cam0", 640, 480, 30, 8)
_STAND = [45, 0, 45, 0, 45, 0, 45, 0, 90, 90]
def stand(dur=10):
for _i, _ang in enumerate(_STAND, 1):
SetTo(f"s{_i}", _ang, dur)
# ---- 动作序列(5×200,T=1000ms → 100 栅格行) ----
# 注:原文件注释「首行不分解,共 81 步」已过时——99c1bc1 起首行也分解为
# 20 个恒等微步,原版/编译版在现行 pybot 语义下均为 100 步且逐元素相等。
stand()
Beep(10, 1000) # spk.F=10 贯穿全程(r2-r5 占位符继承首行)
Delay(200)
for _ang in (60, 45, 30, 45):
for _dev in ("s1", "s3", "s5", "s7"):
SetTo(_dev, _ang, 200)
Delay(200)
+60
View File
@@ -0,0 +1,60 @@
# rght-hand.py —— rght-hand.mt 的 mtc DSL 源码(新格式)
# 右手(右前腿 s7/s8 为 rf,本脚本动 s3/s6 = 左后/左前?保持原脚本语义):
# s6 收回 → s3/s6 展开维持 1s → 收回;蜂鸣 10→400→1000→10 Hz 阶跃。
# 编译:cd mtc && python -m mtc ../bot4cj/scripts/rght-hand.py -o ../bot4cj/scripts/rght-hand.mt
Alias("A", "angle")
Alias("F", "frequency")
I2C("i2c-0", "/dev/i2c-0")
I2C("i2c-1", "/dev/i2c-1")
PCA9685("pca9685-0", "i2c-1", "0x40")
# 姿态传感器按需启用:
# MPU6050_BIND("mpu6050-0", "i2c-1", "0x69")
# MPU6050_BIND("mpu6050-1", "i2c-1", "0x68")
# MPU6050_BIND("mpu6050-2", "i2c-0", "0x68")
# MPU6050_BIND("mpu6050-3", "i2c-0", "0x69")
Speaker("spk", 2, 48000, 0.6, 5000, 0)
# 名 max ch
_SERVOS = [("s1", 90, 0), # rb0
("s2", 180, 1), # rb1
("s3", -90, 2), # lb0
("s4", -180, 3), # lb1
("s5", 90, 4), # lf0
("s6", 180, 5), # lf1
("s7", -90, 6), # rf0
("s8", -180, 7), # rf1
("s9", 180, 8), # cam0
("s10", 180, 9)] # cam1
for _name, _max, _ch in _SERVOS:
Servo(_name, _max, 0, 0, "pca9685-0", _ch)
# 录制源(pybot --record 需要 @Camera 块)
Camera("cam0", 640, 480, 30, 8)
_STAND = [45, 0, 45, 0, 45, 0, 45, 0, 90, 90]
def stand(dur=10):
for _i, _ang in enumerate(_STAND, 1):
SetTo(f"s{_i}", _ang, dur)
# ---- 动作序列(200 + 200 + 1000 + 20,T=1420ms) ----
# spk.F 行级阶跃(pybot 微步对频率列不插值,直接取目标值),与 Beep 恒值段一致
stand()
Beep(10, 200) # r1: F=10
Delay(200)
SetTo("s6", 0, 200) # r2: s6 45→0
Beep(400, 200) # r2: F=400
Delay(200)
SetTo("s3", 0, 1000) # r3: s3 45→0,s6 0→150
SetTo("s6", 150, 1000)
Beep(1000, 1000) # r3: F=1000
Delay(1000)
SetTo("s3", 45, 20) # r4: 收回
SetTo("s6", 0, 20)
Beep(10, 20) # r4: F=10
Delay(20)
+56
View File
@@ -0,0 +1,56 @@
# std.py —— std.mt 的 mtc DSL 源码(新格式)
# Dashun 蜘蛛机器人站立姿态:10 舵机到位 + spk 10Hz 节拍 200ms。
#
# 编译(产物覆盖同目录 std.mt):
# cd mtc && python -m mtc ../bot4cj/scripts/std.py -o ../bot4cj/scripts/std.mt
#
# 与原手写 std.mt 的执行等价性:
# 原 @Action 单行 delay=200,pybot 分解为 20 个恒等微步(首行不插值);
# 本脚本编译为 20 行 delay=10 的常量栅格(servo 段 10ms 到位后保持,
# spk.frequency 段覆盖全程),两者微步序列完全一致
# (tests 验证:两版本 recorder 录制的 actions.npy 逐元素相等)。
Alias("A", "angle")
Alias("F", "frequency")
I2C("i2c-0", "/dev/i2c-0")
I2C("i2c-1", "/dev/i2c-1")
PCA9685("pca9685-0", "i2c-1", "0x40")
# 姿态传感器按需启用(对应原 std.mt 中的注释行):
# MPU6050_BIND("mpu6050-0", "i2c-1", "0x69")
# MPU6050_BIND("mpu6050-1", "i2c-1", "0x68")
# MPU6050_BIND("mpu6050-2", "i2c-0", "0x68")
# MPU6050_BIND("mpu6050-3", "i2c-0", "0x69")
Speaker("spk", 2, 48000, 0.6, 5000, 0) # min_f 原为占位符 '-'(首行解析为 0)
Servo("s1", 90, 0, 0, "pca9685-0", 0) # rb0
Servo("s2", 180, 0, 0, "pca9685-0", 1) # rb1
Servo("s3", -90, 0, 0, "pca9685-0", 2) # lb0
Servo("s4", -180, 0, 0, "pca9685-0", 3) # lb1
Servo("s5", 90, 0, 0, "pca9685-0", 4) # lf0
Servo("s6", 180, 0, 0, "pca9685-0", 5) # lf1
Servo("s7", -90, 0, 0, "pca9685-0", 6) # rf0
Servo("s8", -180, 0, 0, "pca9685-0", 7) # rf1
Servo("s9", 180, 0, 0, "pca9685-0", 8) # cam0
Servo("s10", 180, 0, 0, "pca9685-0", 9) # cam1
# 录制源(pybot --record 需要 @Camera 块)
Camera("cam0", 640, 480, 30, 8)
# stand:10ms(一个栅格步)内到位并保持;首行即目标值,无爬升
SetTo("s1", 45, 10)
SetTo("s2", 0, 10)
SetTo("s3", 45, 10)
SetTo("s4", 0, 10)
SetTo("s5", 45, 10)
SetTo("s6", 0, 10)
SetTo("s7", 45, 10)
SetTo("s8", 0, 10)
SetTo("s9", 90, 10)
SetTo("s10", 90, 10)
# spk 10Hz 持续 200ms —— 对齐原行 spk.F=10 + delay=200;T=200(20 栅格行)
Beep(10, 200)
+55
View File
@@ -0,0 +1,55 @@
# stdd.py —— stdd.mt 的 mtc DSL 源码(新格式)
# 站立 → 四腿 1 号关节(s2/s4/s6/s8)10°→20°→30°→20°→10° 渐摆 → 收回站立。
# 编译:cd mtc && python -m mtc ../bot4cj/scripts/stdd.py -o ../bot4cj/scripts/stdd.mt
Alias("A", "angle")
Alias("F", "frequency")
I2C("i2c-0", "/dev/i2c-0")
I2C("i2c-1", "/dev/i2c-1")
PCA9685("pca9685-0", "i2c-1", "0x40")
# 姿态传感器按需启用:
# MPU6050_BIND("mpu6050-0", "i2c-1", "0x69")
# MPU6050_BIND("mpu6050-1", "i2c-1", "0x68")
# MPU6050_BIND("mpu6050-2", "i2c-0", "0x68")
# MPU6050_BIND("mpu6050-3", "i2c-0", "0x69")
Speaker("spk", 2, 48000, 0.6, 5000, 0) # min_f 原为占位符(首行解析为 0)
# 名 max ch rb=右后 lb=左后 lf=左前 rf=右前 cam=云台
_SERVOS = [("s1", 90, 0), # rb0
("s2", 180, 1), # rb1
("s3", -90, 2), # lb0
("s4", -180, 3), # lb1
("s5", 90, 4), # lf0
("s6", 180, 5), # lf1
("s7", -90, 6), # rf0
("s8", -180, 7), # rf1
("s9", 180, 8), # cam0
("s10", 180, 9)] # cam1
for _name, _max, _ch in _SERVOS:
Servo(_name, _max, 0, 0, "pca9685-0", _ch)
# 录制源(pybot --record 需要 @Camera 块)
Camera("cam0", 640, 480, 30, 8)
_STAND = [45, 0, 45, 0, 45, 0, 45, 0, 90, 90]
def stand(dur=10):
"""全体回站立姿态(dur ms 线性到位);首行用默认 10ms = 瞬时跳变"""
for _i, _ang in enumerate(_STAND, 1):
SetTo(f"s{_i}", _ang, dur)
# ---- 动作序列(对应原 @Action 7 行:200 + 5×50 + 200,T=650ms) ----
stand() # 首行瞬时到位(原首行分解为恒等微步)
Beep(10, 650) # spk.F=10 贯穿全程(原各行占位符继承首行)
Delay(200)
for _ang in (10, 20, 30, 20, 10):
for _dev in ("s2", "s4", "s6", "s8"):
SetTo(_dev, _ang, 50)
Delay(50)
for _dev in ("s2", "s4", "s6", "s8"):
SetTo(_dev, 0, 200) # 收回站立
Delay(200)
+63
View File
@@ -0,0 +1,63 @@
# t0.py —— t0.mt 的 mtc DSL 源码(新格式)
# 对角步态(trot)单周期:左后/右前/左前/右后依次抬摆,spk.F 10→0→200→0 Hz。
# 编译:cd mtc && python -m mtc ../bot4cj/scripts/t0.py -o ../bot4cj/scripts/t0.mt
Alias("A", "angle")
Alias("F", "frequency")
# I2C("i2c-0", "/dev/i2c-0") # 原 t0.mt 中此行为注释
I2C("i2c-1", "/dev/i2c-1")
PCA9685("pca9685-0", "i2c-1", "0x40")
Speaker("spk", 2, 48000, 0.6, 5000, 0) # min_f 原为占位符(首行解析为 0)
# 名 max ch
_SERVOS = [("s1", 90, 0), # rb0
("s2", 180, 1), # rb1
("s3", -90, 2), # lb0
("s4", -180, 3), # lb1
("s5", 90, 4), # lf0
("s6", 180, 5), # lf1
("s7", -90, 6), # rf0
("s8", -180, 7), # rf1
("s9", 180, 8), # cam0
("s10", 180, 9)] # cam1
for _name, _max, _ch in _SERVOS:
Servo(_name, _max, 0, 0, "pca9685-0", _ch)
# 录制源(pybot --record 需要 @Camera 块)
Camera("cam0", 640, 480, 30, 8)
_STAND = [45, 0, 45, 0, 45, 0, 45, 0, 90, 90]
def stand(dur=10):
for _i, _ang in enumerate(_STAND, 1):
SetTo(f"s{_i}", _ang, dur)
# ---- 动作序列(200+20 + 500+20 + 500+20 + 500+20 + 500+20,T=2300ms) ----
# 频率通道:Beep 段外 rest=0 = 静音(对齐原脚本 F=0 / 占位符继承 0 的行)
stand()
Beep(10, 220) # r1-r2: F=10(r2 占位符继承首行)
Delay(200)
SetTo("s1", 90, 20) # r2: left-behind
Delay(20)
SetTo("s1", 45, 500) # r3: stand(F=0 → 段间空隙回落静音)
Delay(500)
SetTo("s3", 90, 20) # r4: right-behind
SetTo("s7", 45, 20)
Delay(20)
SetTo("s3", 45, 500) # r5: stand + F=200
SetTo("s10", 45, 500)
Beep(200, 500)
Delay(500)
SetTo("s5", 90, 20) # r6: right-front(F=0)
Delay(20)
SetTo("s5", 45, 500) # r7: stand
Delay(500)
SetTo("s7", 90, 20) # r8: left-front
Delay(20)
SetTo("s7", 45, 500) # r9: stand
Delay(500)
SetTo("s10", 90, 20) # r10: 云台回 90
Delay(20)
+54
View File
@@ -0,0 +1,54 @@
# tilt-b.py —— tilt-b.mt 的 mtc DSL 源码(新格式)
# 后倾:s2/s4(后腿 1 号关节)10°→30° 渐摆后收回。
# 编译:cd mtc && python -m mtc ../bot4cj/scripts/tilt-b.py -o ../bot4cj/scripts/tilt-b.mt
Alias("A", "angle")
Alias("F", "frequency")
I2C("i2c-0", "/dev/i2c-0")
I2C("i2c-1", "/dev/i2c-1")
PCA9685("pca9685-0", "i2c-1", "0x40")
# 姿态传感器按需启用:
# MPU6050_BIND("mpu6050-0", "i2c-1", "0x69")
# MPU6050_BIND("mpu6050-1", "i2c-1", "0x68")
# MPU6050_BIND("mpu6050-2", "i2c-0", "0x68")
# MPU6050_BIND("mpu6050-3", "i2c-0", "0x69")
Speaker("spk", 2, 48000, 0.6, 5000, 0)
# 名 max ch
_SERVOS = [("s1", 90, 0), # rb0
("s2", 180, 1), # rb1
("s3", -90, 2), # lb0
("s4", -180, 3), # lb1
("s5", 90, 4), # lf0
("s6", 180, 5), # lf1
("s7", -90, 6), # rf0
("s8", -180, 7), # rf1
("s9", 180, 8), # cam0
("s10", 180, 9)] # cam1
for _name, _max, _ch in _SERVOS:
Servo(_name, _max, 0, 0, "pca9685-0", _ch)
# 录制源(pybot --record 需要 @Camera 块)
Camera("cam0", 640, 480, 30, 8)
_STAND = [45, 0, 45, 0, 45, 0, 45, 0, 90, 90]
def stand(dur=10):
for _i, _ang in enumerate(_STAND, 1):
SetTo(f"s{_i}", _ang, dur)
# ---- 动作序列(200 + 5×50 + 200,T=650ms) ----
stand()
Beep(10, 650)
Delay(200)
for _ang in (10, 20, 30, 20, 10):
for _dev in ("s2", "s4"):
SetTo(_dev, _ang, 50)
Delay(50)
for _dev in ("s2", "s4"):
SetTo(_dev, 0, 200)
Delay(200)
+54
View File
@@ -0,0 +1,54 @@
# tilt-f.py —— tilt-f.mt 的 mtc DSL 源码(新格式)
# 前倾:s6/s8(前腿 1 号关节)10°→30° 渐摆后收回。
# 编译:cd mtc && python -m mtc ../bot4cj/scripts/tilt-f.py -o ../bot4cj/scripts/tilt-f.mt
Alias("A", "angle")
Alias("F", "frequency")
I2C("i2c-0", "/dev/i2c-0")
I2C("i2c-1", "/dev/i2c-1")
PCA9685("pca9685-0", "i2c-1", "0x40")
# 姿态传感器按需启用:
# MPU6050_BIND("mpu6050-0", "i2c-1", "0x69")
# MPU6050_BIND("mpu6050-1", "i2c-1", "0x68")
# MPU6050_BIND("mpu6050-2", "i2c-0", "0x68")
# MPU6050_BIND("mpu6050-3", "i2c-0", "0x69")
Speaker("spk", 2, 48000, 0.6, 5000, 0)
# 名 max ch
_SERVOS = [("s1", 90, 0), # rb0
("s2", 180, 1), # rb1
("s3", -90, 2), # lb0
("s4", -180, 3), # lb1
("s5", 90, 4), # lf0
("s6", 180, 5), # lf1
("s7", -90, 6), # rf0
("s8", -180, 7), # rf1
("s9", 180, 8), # cam0
("s10", 180, 9)] # cam1
for _name, _max, _ch in _SERVOS:
Servo(_name, _max, 0, 0, "pca9685-0", _ch)
# 录制源(pybot --record 需要 @Camera 块)
Camera("cam0", 640, 480, 30, 8)
_STAND = [45, 0, 45, 0, 45, 0, 45, 0, 90, 90]
def stand(dur=10):
for _i, _ang in enumerate(_STAND, 1):
SetTo(f"s{_i}", _ang, dur)
# ---- 动作序列(200 + 5×50 + 200,T=650ms) ----
stand()
Beep(10, 650)
Delay(200)
for _ang in (10, 20, 30, 20, 10):
for _dev in ("s6", "s8"):
SetTo(_dev, _ang, 50)
Delay(50)
for _dev in ("s6", "s8"):
SetTo(_dev, 0, 200)
Delay(200)
+54
View File
@@ -0,0 +1,54 @@
# tilt-l.py —— tilt-l.mt 的 mtc DSL 源码(新格式)
# 左倾:s2/s8(右侧腿 1 号关节)10°→30° 渐摆后收回。
# 编译:cd mtc && python -m mtc ../bot4cj/scripts/tilt-l.py -o ../bot4cj/scripts/tilt-l.mt
Alias("A", "angle")
Alias("F", "frequency")
I2C("i2c-0", "/dev/i2c-0")
I2C("i2c-1", "/dev/i2c-1")
PCA9685("pca9685-0", "i2c-1", "0x40")
# 姿态传感器按需启用:
# MPU6050_BIND("mpu6050-0", "i2c-1", "0x69")
# MPU6050_BIND("mpu6050-1", "i2c-1", "0x68")
# MPU6050_BIND("mpu6050-2", "i2c-0", "0x68")
# MPU6050_BIND("mpu6050-3", "i2c-0", "0x69")
Speaker("spk", 2, 48000, 0.6, 5000, 0)
# 名 max ch
_SERVOS = [("s1", 90, 0), # rb0
("s2", 180, 1), # rb1
("s3", -90, 2), # lb0
("s4", -180, 3), # lb1
("s5", 90, 4), # lf0
("s6", 180, 5), # lf1
("s7", -90, 6), # rf0
("s8", -180, 7), # rf1
("s9", 180, 8), # cam0
("s10", 180, 9)] # cam1
for _name, _max, _ch in _SERVOS:
Servo(_name, _max, 0, 0, "pca9685-0", _ch)
# 录制源(pybot --record 需要 @Camera 块)
Camera("cam0", 640, 480, 30, 8)
_STAND = [45, 0, 45, 0, 45, 0, 45, 0, 90, 90]
def stand(dur=10):
for _i, _ang in enumerate(_STAND, 1):
SetTo(f"s{_i}", _ang, dur)
# ---- 动作序列(200 + 5×50 + 200,T=650ms) ----
stand()
Beep(10, 650)
Delay(200)
for _ang in (10, 20, 30, 20, 10):
for _dev in ("s2", "s8"):
SetTo(_dev, _ang, 50)
Delay(50)
for _dev in ("s2", "s8"):
SetTo(_dev, 0, 200)
Delay(200)
+54
View File
@@ -0,0 +1,54 @@
# tilt-r.py —— tilt-r.mt 的 mtc DSL 源码(新格式)
# 右倾:s4/s6(左侧腿 1 号关节)10°→30° 渐摆后收回。
# 编译:cd mtc && python -m mtc ../bot4cj/scripts/tilt-r.py -o ../bot4cj/scripts/tilt-r.mt
Alias("A", "angle")
Alias("F", "frequency")
I2C("i2c-0", "/dev/i2c-0")
I2C("i2c-1", "/dev/i2c-1")
PCA9685("pca9685-0", "i2c-1", "0x40")
# 姿态传感器按需启用:
# MPU6050_BIND("mpu6050-0", "i2c-1", "0x69")
# MPU6050_BIND("mpu6050-1", "i2c-1", "0x68")
# MPU6050_BIND("mpu6050-2", "i2c-0", "0x68")
# MPU6050_BIND("mpu6050-3", "i2c-0", "0x69")
Speaker("spk", 2, 48000, 0.6, 5000, 0)
# 名 max ch
_SERVOS = [("s1", 90, 0), # rb0
("s2", 180, 1), # rb1
("s3", -90, 2), # lb0
("s4", -180, 3), # lb1
("s5", 90, 4), # lf0
("s6", 180, 5), # lf1
("s7", -90, 6), # rf0
("s8", -180, 7), # rf1
("s9", 180, 8), # cam0
("s10", 180, 9)] # cam1
for _name, _max, _ch in _SERVOS:
Servo(_name, _max, 0, 0, "pca9685-0", _ch)
# 录制源(pybot --record 需要 @Camera 块)
Camera("cam0", 640, 480, 30, 8)
_STAND = [45, 0, 45, 0, 45, 0, 45, 0, 90, 90]
def stand(dur=10):
for _i, _ang in enumerate(_STAND, 1):
SetTo(f"s{_i}", _ang, dur)
# ---- 动作序列(200 + 5×50 + 200,T=650ms) ----
stand()
Beep(10, 650)
Delay(200)
for _ang in (10, 20, 30, 20, 10):
for _dev in ("s4", "s6"):
SetTo(_dev, _ang, 50)
Delay(50)
for _dev in ("s4", "s6"):
SetTo(_dev, 0, 200)
Delay(200)
+19
View File
@@ -36,6 +36,25 @@ services:
depends_on: [] depends_on: []
sleep_before: 0 sleep_before: 0
# --- 内存缓存 ---
- name: memdb
cmd: bash /opt/micro_servers/memdb/_run.sh
args:
- "--instance"
- "memdb"
- "--log-level"
- "info"
- "--host"
- "127.0.0.1"
- "--port"
- "8011"
- "--workers"
- "4"
- "--max-time"
- "600"
depends_on: []
sleep_before: 0
# --- AI 审核 --- # --- AI 审核 ---
- name: ai_review - name: ai_review
cmd: bash /opt/micro_servers/ai_review/_run.sh cmd: bash /opt/micro_servers/ai_review/_run.sh
+16 -8
View File
@@ -2,7 +2,7 @@
> 权威架构参考。本文以 `core/services.yaml`(实际运行清单)与 `git HEAD` 为事实来源。 > 权威架构参考。本文以 `core/services.yaml`(实际运行清单)与 `git HEAD` 为事实来源。
> 与根目录 `README.md` 的偏差见文末「§10 与 README 的偏差」。 > 与根目录 `README.md` 的偏差见文末「§10 与 README 的偏差」。
> 最后核对于:2026-08-10。 > 最后核对于:2026-08-17。
--- ---
@@ -31,8 +31,8 @@ Blackbird 是一套 **进程托管式轻量微服务** 系统(自研 UniServe
┌─────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────┐
│ Blackbird 系统边界 │ │ Blackbird 系统边界 │
│ waf → ca → gateway → {fsys, dnfs2, core} │ │ waf → ca → gateway → {fsys, dnfs2, core} │
│ 内部:db2 / ai_review / smtp / captcha2 / core(confs) │ │ 内部:db2 / memdb / ai_review / smtp / captcha2 / core(confs) │
│ 独立:aibox(+BotOps) / bot4cj / lci │ │ 独立:aibox(+BotOps) / bot4cj+pybot / lci │
└─────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────┘
│ pymsg/TCP (内部 RPC) │ SMTP (外部邮件) │ pymsg/TCP (内部 RPC) │ SMTP (外部邮件)
▼ ▼ ▼ ▼
@@ -85,14 +85,17 @@ Blackbird 是一套 **进程托管式轻量微服务** 系统(自研 UniServe
└─────────────────────────────────────┘ └─────────────────────────────────────┘
独立 / 外设(不在主链路): 独立 / 外设(不在主链路):
aibox :8765 (Python WebSocket, 独立产品) aibox :8765 (Python WebSocket, 独立产品; --once CLI 单次对话 + models 多模型档案含 fallback 失败转移)
BotOps (aibox 的 TUI 前端) BotOps (aibox 的 TUI 前端)
bot4cj (Cangjie + 树莓派 I2C, 硬件) bot4cj (Cangjie + 树莓派 I2C, 硬件)
pybot (Python, bot4cj 的迁移版: .mt 解析/录制/实时闭环)
mtc (Python, 命令式动作脚本 → .mt 编译器, 开发期工具)
lci (C++ /dev/fb0, 嵌入式 GUI) lci (C++ /dev/fb0, 嵌入式 GUI)
actpred / memdb / libtry (实验 / 支撑库) actpred / libtry (实验 / 支撑库)
日志链路: 各服务 log_client ──pymsg──► db2:8005 Logs 表 (fail-safe, db 不可用时静默丢弃) 日志链路: 各服务 log_client ──pymsg──► db2:8005 Logs 表 (fail-safe, db 不可用时静默丢弃)
配置链路: gateway ──pymsg get──► core 内嵌 confs :8010 (confs/configs/gateway.ini, mtime 自动重载;其余服务配置纯 CLI 传递) 配置链路: gateway ──pymsg get──► core 内嵌 confs :8010 (confs/configs/gateway.ini, mtime 自动重载;其余服务配置纯 CLI 传递)
内存缓存: memdb :8011 (libmemdb + libmsg RPC, TTL 惰性驱逐;暂无业务调用方,预留热点数据/会话缓存)
``` ```
> 注:`core` 启动参数为 `./nidus.out -i . --serve 5200 --confs 8010`(`-i .` 指向 services.yaml 所在目录),`--confs` 启用内嵌配置服务(由 confs2 合并而来,与 Python confs 协议兼容)。 > 注:`core` 启动参数为 `./nidus.out -i . --serve 5200 --confs 8010`(`-i .` 指向 services.yaml 所在目录),`--confs` 启用内嵌配置服务(由 confs2 合并而来,与 Python confs 协议兼容)。
@@ -138,15 +141,17 @@ Blackbird 是一套 **进程托管式轻量微服务** 系统(自研 UniServe
| **fsys** | C++20 | 9200 | 127.0.0.1 | 前端托管 + dnfs 代理(static 的 C++ 重写) | 生产 | | **fsys** | C++20 | 9200 | 127.0.0.1 | 前端托管 + dnfs 代理(static 的 C++ 重写) | 生产 |
| **dnfs2** | C++20 | 8006 | 127.0.0.1 | 树状笔记 CRUD | 生产(dnfs 的 C++ 重写) | | **dnfs2** | C++20 | 8006 | 127.0.0.1 | 树状笔记 CRUD | 生产(dnfs 的 C++ 重写) |
| **db2** | C++20 | 8005 | 127.0.0.1 | 通用 DB(pymsg execute) + 日志汇聚 | 生产(db 的 C++ 重写) | | **db2** | C++20 | 8005 | 127.0.0.1 | 通用 DB(pymsg execute) + 日志汇聚 | 生产(db 的 C++ 重写) |
| **memdb** | C++20 | 8011 | 127.0.0.1 | 内存缓存数据库(libmemdb + libmsg RPC,TTL 惰性驱逐,9 路由) | 生产(2026-08 合入 services.yaml) |
| **ai_review** | Python | 8007 | 127.0.0.1 | AI 内容审核(DeepSeek + ChromaDB RAG) | 生产 | | **ai_review** | Python | 8007 | 127.0.0.1 | AI 内容审核(DeepSeek + ChromaDB RAG) | 生产 |
| **smtp** | Python | 8008 | 127.0.0.1 | 邮件验证码后端(pymsg) | 生产(smtp3 C++ 重写待上线) | | **smtp** | Python | 8008 | 127.0.0.1 | 邮件验证码后端(pymsg) | 生产(smtp3 C++ 重写待上线) |
| **captcha2** | C++20 | 8009 | 127.0.0.1 | 图形验证码(pymsg) | 生产(captcha 的 C++ 重写) | | **captcha2** | C++20 | 8009 | 127.0.0.1 | 图形验证码(pymsg) | 生产(captcha 的 C++ 重写) |
| **aibox** | Python | 8765 | 127.0.0.1 | 独立 AI 智能体(WebSocket) | 独立产品 | | **aibox** | Python | 8765 | 127.0.0.1 | 独立 AI 智能体(WebSocket) | 独立产品 |
| **BotOps** | Python | — | — | aibox 的 TUI 前端 | 独立 | | **BotOps** | Python | — | — | aibox 的 TUI 前端 | 独立 |
| **bot4cj** | Cangjie | — | 树莓派 I2C | 机器人脚本解析器 | 独立硬件 | | **bot4cj** | Cangjie | — | 树莓派 I2C | 机器人脚本解析器 | 独立硬件 |
| **pybot** | Python | — | 树莓派 | bot4cj 的 Python 迁移版(.mt 解析、actpred episode 录制、--loop 实时预测闭环;HEF 后端自动异步,推理与执行上一步动作并行) | 独立硬件 |
| **mtc** | Python | — | — | 命令式动作脚本(Python DSL)→ .mt 编译器(非阻塞时间轴 IR + 10ms 栅格化),pybot/bot4cj 的脚本 authoring 工具 | 开发期工具 |
| **lci** | C++ | — | /dev/fb0 | 嵌入式帧缓冲 GUI | 独立硬件 | | **lci** | C++ | — | /dev/fb0 | 嵌入式帧缓冲 GUI | 独立硬件 |
| **actpred** | Python | — | — | ML 预训练/推理 | 实验 | | **actpred** | Python | — | — | 动作预测(图像+动作历史→下一动作),bot4cj/pybot 的「大脑」配套 | 实验 |
| **memdb** | C++20 | — | — | 内存数据库 | 实验/支撑 |
| **libtry** | C | — | — | 嵌入式脚本解释器 | 支撑 | | **libtry** | C | — | — | 嵌入式脚本解释器 | 支撑 |
### 已被替代、不应再部署的遗留模块(不在 services.yaml) ### 已被替代、不应再部署的遗留模块(不在 services.yaml)
@@ -170,6 +175,7 @@ Blackbird 是一套 **进程托管式轻量微服务** 系统(自研 UniServe
| libcrypt | C++20 | AES_CBC / RSA / SHA256 / Base64 | | libcrypt | C++20 | AES_CBC / RSA / SHA256 / Base64 |
| libsqlite3 | C++20 | SQLite3 header-only 封装 | | libsqlite3 | C++20 | SQLite3 header-only 封装 |
| libmsg | C++20 | pymsg 协议 C++ 实现(4B 大端长度前缀 + UTF-8 JSON),与 Python pymsg 字节级兼容,提供 RpcClient/RpcServer/AsyncQueue | | libmsg | C++20 | pymsg 协议 C++ 实现(4B 大端长度前缀 + UTF-8 JSON),与 Python pymsg 字节级兼容,提供 RpcClient/RpcServer/AsyncQueue |
| libmemdb | C++20 | header-only 内存数据库模板库:三索引存储(list 主存 + 键索引 + TTL 索引),TTL 惰性驱逐;memdb 服务基于此库 |
| liblogc | C++20 | 统一集中式日志客户端(`us/LogC.hpp`,对应 Python `log_client.py`):pymsg/TCP 异步批量写 db2 Logs 表,队列上限 1 万条 + 级别过滤 + fail-safe,C++ 服务共用 | | liblogc | C++20 | 统一集中式日志客户端(`us/LogC.hpp`,对应 Python `log_client.py`):pymsg/TCP 异步批量写 db2 Logs 表,队列上限 1 万条 + 级别过滤 + fail-safe,C++ 服务共用 |
| libtry | C | 嵌入式脚本解释器(递归下降,中文标识符) | | libtry | C | 嵌入式脚本解释器(递归下降,中文标识符) |
| pymsg | Python | 自研 RPC 基础库(`[4B 大端长度][UTF-8 JSON]`),Python 侧 db/log_client 依赖 | | pymsg | Python | 自研 RPC 基础库(`[4B 大端长度][UTF-8 JSON]`),Python 侧 db/log_client 依赖 |
@@ -195,7 +201,7 @@ Blackbird 是一套 **进程托管式轻量微服务** 系统(自研 UniServe
- 三层覆盖:confs 默认值 → 环境变量 → 命令行参数。 - 三层覆盖:confs 默认值 → 环境变量 → 命令行参数。
### 7.4 弹性 / 容错 ### 7.4 弹性 / 容错
- 启动顺序敏感:`core` 拉起顺序为 db2→ai_review→dnfs2→smtp→fsys→waf→ca→gateway→captcha2→aibox→redirect80;pymsg 客户端带 `retry_on_refused` 等依赖就绪。 - 启动顺序敏感:`core` 拉起顺序为 db2→memdb→ai_review→dnfs2→smtp→fsys→waf→ca→gateway→captcha2→aibox→redirect80(共 12 个);pymsg 客户端带 `retry_on_refused` 等依赖就绪。
- 日志 fail-safe:db2 不可用时静默丢弃,不影响业务。 - 日志 fail-safe:db2 不可用时静默丢弃,不影响业务。
- 有状态单点:`smtp` / `captcha2` 验证码存进程内存,**不支持多实例水平扩展**(需换 Redis 才能扩)。 - 有状态单点:`smtp` / `captcha2` 验证码存进程内存,**不支持多实例水平扩展**(需换 Redis 才能扩)。
@@ -230,6 +236,7 @@ Blackbird 是一套 **进程托管式轻量微服务** 系统(自研 UniServe
- **smtp3 待上线**:C++ 重写已完成并通过安全审计,但 services.yaml 仍指向 Python `smtp`,建议择机平替以消除最后一块 Python 有状态服务。 - **smtp3 待上线**:C++ 重写已完成并通过安全审计,但 services.yaml 仍指向 Python `smtp`,建议择机平替以消除最后一块 Python 有状态服务。
- **可观测性缺口**:缺 tracing/metrics/告警,建议后续引入轻量方案(或复用 db2 Logs + 定时聚合)。 - **可观测性缺口**:缺 tracing/metrics/告警,建议后续引入轻量方案(或复用 db2 Logs + 定时聚合)。
- **水平扩展限制**:smtp/captcha 内存态为单点,若需多实例需引入 Redis 等共享存储。 - **水平扩展限制**:smtp/captcha 内存态为单点,若需多实例需引入 Redis 等共享存储。
- **panel 面板微服务(规划中)**:设计稿见 [docs/panel_service.md](panel_service.md)——背景视图 + 各服务自持控件片段(iframe 沙箱 + BFF 转发),解决整页应用互相改前端代码的耦合问题;落地后需三处同步(本文件 / MODULES.md / 根 README)。
--- ---
@@ -244,6 +251,7 @@ Blackbird 是一套 **进程托管式轻量微服务** 系统(自研 UniServe
5. **fsys README 图示滞后**:✅ 已修正(2026-08-10)。`fsys/README.md` 架构图与配置章节已更新为 `ca:8443 → gateway:8445`、纯 CLI 配置。 5. **fsys README 图示滞后**:✅ 已修正(2026-08-10)。`fsys/README.md` 架构图与配置章节已更新为 `ca:8443 → gateway:8445`、纯 CLI 配置。
6. **工作记忆偏差**:✅ 已同步。`ca:8443 → gateway:8445`、`dnfs2`、confs 内嵌 core 均已写入各文档。 6. **工作记忆偏差**:✅ 已同步。`ca:8443 → gateway:8445`、`dnfs2`、confs 内嵌 core 均已写入各文档。
7. **dnfs2 README 图示滞后**:✅ 已修正(2026-08-10)。`dnfs2/README.md` 架构图与验签描述已更新为 ca:8443(CLI 参数 `--gateway-url` 指向 ca)。 7. **dnfs2 README 图示滞后**:✅ 已修正(2026-08-10)。`dnfs2/README.md` 架构图与验签描述已更新为 ca:8443(CLI 参数 `--gateway-url` 指向 ca)。
8. **memdb / pybot / mtc 未进根 README**:✅ 已回填(2026-08-15)。`memdb:8011`、`pybot`(bot4cj Python 迁移版)、`mtc`(Python DSL → .mt 编译器,2026-08-15 落地)连同 `actpred` 已入根 `README.md` 模块表与端口一览;本文件与 MODULES.md 已同步。
--- ---
+10 -6
View File
@@ -1,7 +1,7 @@
# 模块清单(blackbird / micro_servers) # 模块清单(blackbird / micro_servers)
> 一套 DIY 的轻量级「进程托管式微服务」(UniServe Framework)。 > 一套 DIY 的轻量级「进程托管式微服务」(UniServe Framework)。
> 混合语言:C++20(core/gateway/ca/dnfs2/db2/fsys/captcha2/lib*)、Python(static/dnfs/db/ai_review/smtp/captcha/confs/redirect80/BotOps/aibox/actpred/pymsg,多为遗留)、Go(waf)、仓颉/Cangjie(bot4cj)。 > 混合语言:C++20(core/gateway/ca/dnfs2/db2/memdb/fsys/captcha2/lib*)、Python(static/dnfs/db/ai_review/smtp/captcha/confs/redirect80/BotOps/aibox/actpred/pybot/mtc/pymsg,多为遗留)、Go(waf)、仓颉/Cangjie(bot4cj)。
> 由 `core` 读 `services.yaml` 拉起并守护各子进程;`waf:443 → ca:8443 → gateway:8445 → 业务服务` 为对外主链路。 > 由 `core` 读 `services.yaml` 拉起并守护各子进程;`waf:443 → ca:8443 → gateway:8445 → 业务服务` 为对外主链路。
## 一、核心与编排 ## 一、核心与编排
@@ -38,12 +38,13 @@
| smtp | Python/pymsg | 8008 | 邮件服务,注册邮箱验证码后端(路由 send_email/verify_email/health),6 位码内存存储、冷却/限流/熔断(smtp3 C++ 重写待上线) | | smtp | Python/pymsg | 8008 | 邮件服务,注册邮箱验证码后端(路由 send_email/verify_email/health),6 位码内存存储、冷却/限流/熔断(smtp3 C++ 重写待上线) |
| captcha | Python/pymsg | 8009 | 验证码服务,图形验证码(一次性、5 分钟过期),经 ca `GET /captcha/image` 代理给浏览器(遗留,已由 captcha2 平替) | | captcha | Python/pymsg | 8009 | 验证码服务,图形验证码(一次性、5 分钟过期),经 ca `GET /captcha/image` 代理给浏览器(遗留,已由 captcha2 平替) |
| captcha2 | C++20 | 8009 | captcha 的 C++20 重实现(pymsg),路由 image/verify/health 对齐 | | captcha2 | C++20 | 8009 | captcha 的 C++20 重实现(pymsg),路由 image/verify/health 对齐 |
| memdb | C++20 | 8011 | 内存缓存数据库(libmemdb 模板库 + libmsg RPC),TTL 惰性驱逐,9 路由(ping/set/get/del/update/exists/size/show/expire),线程安全 + SIGINT/SIGTERM 优雅退出 |
## 五、AI 智能体与前端 ## 五、AI 智能体与前端
| 模块 | 语言 | 端口 | 职责 | | 模块 | 语言 | 端口 | 职责 |
|------|------|------|------| |------|------|------|------|
| aibox | Python | 8765 | 独立 AI 智能体系统(DeepSeek + WebSocket),自带技能系统/记忆/任务调度/前端与语音客户端 | | aibox | Python | 8765 | 独立 AI 智能体系统(DeepSeek + WebSocket),自带技能系统/记忆/任务调度/前端与语音客户端;`--once` CLI 单次对话(stdout/stderr 分流),`models` 配置节支持多 OpenAI 兼容端点按档案名切换 |
| BotOps | Python/Textual | — | aibox 的终端 TUI 前端,WebSocket 连接 aibox,Markdown 增量流式渲染、思考模式、任务侧栏、自动重连 | | BotOps | Python/Textual | — | aibox 的终端 TUI 前端,WebSocket 连接 aibox,Markdown 增量流式渲染、思考模式、任务侧栏、自动重连 |
| actpred | Python/PyTorch | — | 动作预测(图像 + 动作历史 → 预测下一个动作),bot4cj 的「大脑」配套;支持从 `.mt` 脚本预训练 | | actpred | Python/PyTorch | — | 动作预测(图像 + 动作历史 → 预测下一个动作),bot4cj 的「大脑」配套;支持从 `.mt` 脚本预训练 |
@@ -52,6 +53,8 @@
| 模块 | 语言 | 端口 | 职责 | | 模块 | 语言 | 端口 | 职责 |
|------|------|------|------| |------|------|------|------|
| bot4cj | 仓颉/Cangjie | — (树莓派 I2C) | 机器人硬件脚本解析器,解析 `.mt` 驱动 PCA9685 舵机 / MPU6050 陀螺仪 / 扬声器,面向 Dashun 蜘蛛机器人 | | bot4cj | 仓颉/Cangjie | — (树莓派 I2C) | 机器人硬件脚本解析器,解析 `.mt` 驱动 PCA9685 舵机 / MPU6050 陀螺仪 / 扬声器,面向 Dashun 蜘蛛机器人 |
| pybot | Python | — (树莓派) | bot4cj 的 Python 迁移版:`.mt` 动作脚本解析器(零第三方依赖,与仓颉版逐语义对齐);支持执行 / `--dry-run` / `--record` 录制 actpred episode(含 imu.npy)/ `--loop` 实时预测闭环(HEF 后端自动异步:推理与执行上一步动作并行) |
| mtc | Python | — | 命令式动作脚本(Python DSL)→ `.mt` 编译器:`exec` 注入命名空间 → 时间轴 IR(非阻塞语义、重叠截断)→ 10ms 栅格化;产物可由 pybot/bot4cj 直接执行,零第三方依赖 |
| lci | C++ | — (/dev/fb0) | 嵌入式帧缓冲图形界面(无需 X11/Wayland),矢量 GB2312 中文字库、图片渲染、实时数据可视化 | | lci | C++ | — (/dev/fb0) | 嵌入式帧缓冲图形界面(无需 X11/Wayland),矢量 GB2312 中文字库、图片渲染、实时数据可视化 |
| 3d | — (资产) | — | 机器人 / 机械臂 CAD 模型(STEP/STL/SLDPRT),bot4cj 配套硬件资产 | | 3d | — (资产) | — | 机器人 / 机械臂 CAD 模型(STEP/STL/SLDPRT),bot4cj 配套硬件资产 |
@@ -64,17 +67,17 @@
| libcrypt | C++ | 加密库:`AES_CBC`、`MyRSA`、`SHA256`、`Base64`、`Crypto_Basic` | | libcrypt | C++ | 加密库:`AES_CBC`、`MyRSA`、`SHA256`、`Base64`、`Crypto_Basic` |
| libsqlite3 | C++ | SQLite3 header-only 封装 | | libsqlite3 | C++ | SQLite3 header-only 封装 |
| libmsg | C++ | pymsg 协议 C++ 实现(4B 大端长度前缀 + UTF-8 载荷),`MsgClient/Server`、`RpcClient/Server`、`AsyncQueue`,与 Python pymsg 字节级兼容 | | libmsg | C++ | pymsg 协议 C++ 实现(4B 大端长度前缀 + UTF-8 载荷),`MsgClient/Server`、`RpcClient/Server`、`AsyncQueue`,与 Python pymsg 字节级兼容 |
| libmemdb | C++20 | header-only 内存数据库模板库:三索引存储(`std::list` 主存 + `unordered_map` 键索引 + `multiset` TTL 索引),insert/remove/update/search 等操作,TTL 惰性驱逐;memdb 服务即基于此库 |
| liblogc | C++ | 统一集中式日志客户端(`us/LogC.hpp`,对应 Python `log_client.py`):pymsg/TCP 异步批量写 db2 Logs 表,队列上限 1 万条 + 级别过滤 + fail-safe,ca/core/gateway/captcha2/smtp3 等 C++ 服务共用 | | liblogc | C++ | 统一集中式日志客户端(`us/LogC.hpp`,对应 Python `log_client.py`):pymsg/TCP 异步批量写 db2 Logs 表,队列上限 1 万条 + 级别过滤 + fail-safe,ca/core/gateway/captcha2/smtp3 等 C++ 服务共用 |
| libtry | C | 嵌入式脚本解释器(~741 行),手写递归下降,支持中文标识符,用于嵌入式操作界面 shell | | libtry | C | 嵌入式脚本解释器(~741 行),手写递归下降,支持中文标识符,用于嵌入式操作界面 shell |
| pymsg | Python | 自研 RPC 基础库(`[4B 长度][UTF-8 JSON]`),db 服务端与各 `log_client.py` 的基础依赖(setuptools 包) | | pymsg | Python | 自研 RPC 基础库(`[4B 长度][UTF-8 JSON]`),db 服务端与各 `log_client.py` 的基础依赖(setuptools 包) |
| memdb | C++ | 内存数据库(include/src),内部组件,未在主 README 模块表列出 |
## 八、测试与文档 ## 八、测试与文档
| 模块 | 说明 | | 模块 | 说明 |
|------|------| |------|------|
| tests | 黑盒测试套件(`test_db.py`/`test_gateway.py`/`test_waf.py`/`test_dnfs.py`/`test_captcha.py`/`test_smtp.py`/`test_ai_review.py`/`test_static.py`/`test_core_confs.py`),纯标准库 HTTP/TCP 用户旅程模拟,经 `test_runner.py` 统一调度(详见 [tests/blackbox/README.md](../tests/blackbox/README.md)) | | tests | 黑盒测试套件(`test_db.py`/`test_gateway.py`/`test_waf.py`/`test_dnfs.py`/`test_captcha.py`/`test_smtp.py`/`test_ai_review.py`/`test_static.py`/`test_core_confs.py`),纯标准库 HTTP/TCP 用户旅程模拟,经 `test_runner.py` 统一调度(详见 [tests/blackbox/README.md](../tests/blackbox/README.md)) |
| docs | 文档聚合(security/ 审计、reviews/ 审查、reports/ 分析),57 篇 md | | docs | 文档聚合(security/ 审计、reviews/ 审查、reports/ 分析),76 篇 md |
## 九、根级构建与部署脚本 ## 九、根级构建与部署脚本
@@ -88,5 +91,6 @@
- **请求主链路**:client → `redirect80`(:80→443) → `waf`(:443 TLS + WAF) → `ca`(:8443 认证+前置转发) → `gateway`(:8445 纯转发) → `fsys`(:9200) / `dnfs2`(:8006) - **请求主链路**:client → `redirect80`(:80→443) → `waf`(:443 TLS + WAF) → `ca`(:8443 认证+前置转发) → `gateway`(:8445 纯转发) → `fsys`(:9200) / `dnfs2`(:8006)
- **日志链路**:各服务 `log_client.py`(异步批量)→ `db2:8005` Logs 表(fail-safe,db2 不可用时静默丢弃) - **日志链路**:各服务 `log_client.py`(异步批量)→ `db2:8005` Logs 表(fail-safe,db2 不可用时静默丢弃)
- **配置链路**:`gateway` → `core` 内嵌 confs:8010(内存缓存 + mtime 重载,仅 gateway.ini);其余服务配置纯 CLI 传递 - **配置链路**:`gateway` → `core` 内嵌 confs:8010(内存缓存 + mtime 重载,仅 gateway.ini);其余服务配置纯 CLI 传递
- **数据/审核/邮件**:`dnfs2` → `db2:8005`(pymsg 持久化);`dnfs2` → `ai_review:8007`(审核);注册邮件 `ca` → `smtp:8008`;登录验证码 `ca` → `captcha2:8009` - **数据/审核/邮件/缓存**:`dnfs2` → `db2:8005`(pymsg 持久化);`dnfs2` → `ai_review:8007`(审核);注册邮件 `ca` → `smtp:8008`;登录验证码 `ca` → `captcha2:8009`;`memdb:8011`(内存缓存,libmsg RPC,暂无业务调用方,预留热点数据/会话缓存)
- **端口一览**:80 redirect80 · 443 waf · 8443 ca · 8445 gateway · 5200/8010 core · 9200 fsys · 8006 dnfs2 · 8005 db2 · 8007 ai_review · 8009 captcha2 · 8008 smtp · 8765 aibox - **机器人数据链**:`mtc` 编译 Python DSL → `.mt`(或手写 `.mt`)→ `pybot` 执行 / `--record` 录制 episode(images + actions.npy + imu.npy)→ `actpred` 两阶段训练 → ONNX/Hailo HEF 部署(`predict_async` 异步流水线)→ `pybot --loop` 实时预测闭环(bot4cj 语义对齐,HEF 后端推理与执行并行)
- **端口一览**:80 redirect80 · 443 waf · 8443 ca · 8445 gateway · 5200/8010 core · 9200 fsys · 8006 dnfs2 · 8005 db2 · 8007 ai_review · 8009 captcha2 · 8008 smtp · 8011 memdb · 8765 aibox
+254
View File
@@ -0,0 +1,254 @@
# 面板微服务(panel)开发文档
> 状态:设计稿 v0.1(开发前评审)
> 关联:[gateway/README.md](../gateway/README.md)、[docs/dnfs_custom_themes.md](dnfs_custom_themes.md)、[core/services.yaml](../core/services.yaml)、[libhttp/README.md](../libhttp/README.md)
## 1. 背景与目标
### 1.1 问题
现有前端页面(fsys 托管的 `index.html` / `dnfs.html` 等)都是"整页应用":每个页面由单个服务完整提供。当多个服务都想在首页/工作台展示自己的能力时,只能互相改动前端代码或各自开新页面,服务间耦合、发布相互牵连。
### 1.2 目标
新增**面板微服务**(panel),提供一张**背景视图**(工作台/仪表盘)。视图内的元素与控件**不由面板自己实现**,而是由各业务微服务以**前端代码片段**的形式提供,面板负责聚合、渲染与托管:
- 各服务**自持控件代码**(代码维护在各自模块),通过约定协议暴露片段
- 面板**拉取并聚合**片段,以 iframe 沙箱插入背景视图
- 面板只提供统一外壳(布局 / 主题 / 通信 / 身份透传),**不感知控件业务**
### 1.3 非目标(v1)
- 不做控件可视化拖拽编排(v2 候选)
- 不做用户自定义控件 / 第三方插件市场(控件仅来自内部服务,白名单)
- 不做控件代码的 AI 内容审核——控件是内部服务代码而非用户输入,信任边界与 dnfs2 用户样式节点不同(见 5.3)
## 2. 架构设计
### 2.1 拓扑
```
浏览器
│ https + q4_token Cookie / Authorization
▼
waf:443 ──► ca:8443(验签,注入 X-Auth-User / X-Auth-Admin)
│ Target-Url: http://127.0.0.1:9201
▼
gateway:8445 ──► panel:9201(Proxy.opened 白名单)
│
┌────────────────┼──────────────────────┐
│ 内部 HTTP(--providers 白名单) │ BFF 转发
▼ ▼ ▼
dnfs2:8006 fsys:9200 其他 provider 服务
/panel-widget/x /panel-widget/y
```
- 面板对浏览器只暴露**一个 origin**(面板自己);控件 iframe 由面板端点在**同一 origin** 下渲染,不存在跨域、自定义头问题(浏览器无法设置 `Target-Url`,因此控件回源统一走面板 BFF,对齐 fsys→dnfs2 的服务端代理先例)。
- 面板加入 `Proxy.opened` 白名单后走 ca 受保护链路:只有登录用户能打开面板;BFF 转发时透传身份头。
### 2.2 核心概念
| 概念 | 说明 |
|---|---|
| Panel | 面板服务本身:背景视图 + 聚合 / 渲染 / 托管 |
| Widget(控件) | 一个可独立渲染的前端单元,由某微服务提供 |
| Provider(提供方) | 拥有控件代码的微服务,实现 `/panel-widget/<id>` 协议 |
| Manifest | 控件描述:元数据 + html/css/js 片段 + 布局 / 主题声明 |
### 2.3 控件生命周期
1. **声明**:服务在自身实现 `GET /panel-widget/<id>`(内部 HTTP)
2. **拉取**:面板按 `--widgets` 清单拉取各 manifest(缓存 + TTL)
3. **聚合**:面板把 manifest 渲染为独立子页面 `/widget/<id>`(内联片段 + 白名单 CSP)
4. **插入**:面板页用 `<iframe sandbox="allow-scripts">` 加载子页面,完成"背景视图 + 控件"组合
5. **通信**:控件与面板用 postMessage 协议交互(就绪 / 调尺寸 / 主题 / 导航)
6. **回源**:控件业务数据经面板 BFF 转发到所属服务(透传身份头)
### 2.4 端口与技术栈
| 项 | 值 | 理由 |
|---|---|---|
| 端口 | 9201 | 与 fsys:9200 相邻的 HTTP 对外段;8xxx 保留给 pymsg/TCP 内部服务 |
| 语言 | C++20 + libhttp | 与 db2 / dnfs2 / fsys / captcha2 生态一致 |
| 日志 | liblogc(`us/LogC.hpp`) | 统一异步推送 db2 Logs 表 |
## 3. 服务接口
### 3.1 对外(浏览器,经 gateway)
| 路由 | 方法 | 说明 |
|---|---|---|
| `/` | GET | 面板页(背景视图 + 控件网格) |
| `/widgets` | GET | 控件清单 JSON(面板缓存内的 manifest 元数据 + 状态),供页面 JS 渲染网格 |
| `/widget/<id>` | GET | 控件子页面(内联 html/css/js),供 iframe 加载;响应带独立 CSP(见 5.1) |
| `/api/w/<id>/<path...>` | 任意 | BFF:转发到 `<id>` 所属 provider,透传身份头 |
| `/health` | GET | 健康检查 |
### 3.2 对 Provider(内部 HTTP 协议)
```
GET /panel-widget/<widget_id>
200 OK, application/json
{
"id": "dnfs2-minimap", // 必须与路径一致
"title": "树状菜单迷你图", // 面板标题(≤ 64 字符)
"version": 3, // 递增版本,作为面板缓存键
"size": {"w": 2, "h": 1}, // 网格单位(1 ≤ w,h ≤ 6)
"themeable": true, // 是否响应面板主题事件
"html": "<div class=\"w-root\">…</div>",
"css": ".w-root { … }",
"js": "window.wx.ready(); …"
}
```
面板强制校验(失败视为该控件不可用,返回占位):
- `html` / `css` / `js` 各 ≤ 256KB;`id` 必须与 URL 一致;所有字段必须存在
- 拉取超时 3s;TTL 默认 60s(`--cache-ttl`)
- provider 不可达 / 校验失败 → 占位卡片「控件暂不可用」,**fail-soft**:面板是展示性服务,一个控件失败不拖垮整页(区别于 ai_review 审核类的 fail-closed 语义)
### 3.3 面板配置(core/services.yaml)
```yaml
- name: panel
cmd: bash /opt/micro_servers/panel/_run.sh
args:
- "--instance"
- "panel"
- "--log-level"
- "info"
- "--host"
- "127.0.0.1"
- "--port"
- "9201"
# 控件可见清单(由面板控制;未列出的控件不渲染)
- "--widgets"
- "dnfs2-minimap;dnfs2-recent;fsys-storage"
# 提供方映射(白名单:面板只向这些地址拉取 / 转发)
- "--providers"
- "dnfs2=http://127.0.0.1:8006;fsys=http://127.0.0.1:9200"
- "--cache-ttl"
- "60"
depends_on: [dnfs2, fsys]
sleep_before: 0
```
同时修改 `core/configs/gateway.ini` 白名单:
```ini
[Proxy]
opened = http://127.0.0.1:9200;http://127.0.0.1:8006;http://127.0.0.1:5200;http://127.0.0.1:9201;
```
## 4. 控件协议(前端侧)
### 4.1 运行环境约定
控件运行在 `<iframe sandbox="allow-scripts">` 中:
- **无 `allow-same-origin`**:iframe 内为 opaque origin,读不到主页面 DOM / Cookie / localStorage
- 控件只能:渲染自己的片段、发 postMessage、`fetch` 面板同源路径(经 BFF)
- 控件不得假设自己就是 provider 域:所有业务调用走 `fetch('/api/w/<id>/…')`
### 4.2 postMessage 事件
面板 → 控件:
| 事件 | payload | 说明 |
|---|---|---|
| `panel.theme` | `{theme:"dark"\|"light"}` | 主题切换(仅 `themeable: true` 的控件) |
| `panel.user` | `{user:"user@example.com"}` | 当前登录用户(控件需要个性化时) |
控件 → 面板:
| 事件 | payload | 说明 |
|---|---|---|
| `widget.ready` | `{id}` | 控件渲染完成,面板收起 loading |
| `widget.resize` | `{id, w, h}` | 请求调整网格占位(面板限幅在声明 size 的 ±2 内) |
| `widget.navigate` | `{id, url}` | 请求面板同源跳转(url 必须同 origin,面板校验) |
| `widget.toast` | `{id, message}` | 轻提示 |
| `widget.log` | `{id, level, message}` | 前端日志(面板转发 db2 Logs) |
面板侧校验:优先校验 `e.origin === location.origin`;`payload.id` 必须与绑定的 iframe 一致;必要时以 `e.source === iframe.contentWindow` 兜底校验(opaque origin 场景)。
### 4.3 布局与主题
- v1 用 CSS Grid(12 列),按 `manifest.size` 自动流式排布(`grid-auto-flow: dense`),控件间留隙
- 面板提供统一控件外壳(标题栏 = title + 版本 + 刷新按钮),内容区为 iframe
- 主题变量沿用 dnfs2 风格(`--bg` / `--fg` / `--accent` / …),切换时经 `panel.theme` 同步给 themeable 控件
## 5. 安全设计
### 5.1 控件隔离(核心决策)
**用 iframe 子页面端点 `/widget/<id>`,而非 `srcdoc`。** 原因:
- `srcdoc` 文档**继承父页面 CSP**;面板页若保持严格 CSP(`script-src 'self'`),会连带封死控件的内联脚本
- 独立端点可携带**控件文档自己的 CSP**,实现"父页面严格、控件文档自管"的分层
```http
HTTP/1.1 200 OK
Content-Type: text/html
Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; sandbox allow-scripts
```
配合 iframe 属性双保险:`<iframe sandbox="allow-scripts" src="/widget/<id>">`。即使 CSP 头缺失,`sandbox` 属性仍保证 opaque origin + 禁止顶层导航。
### 5.2 信任边界
| 边界 | 策略 |
|---|---|
| provider 拉取 | 仅 `--providers` 白名单地址,内部 HTTP;manifest 全字段校验 + 大小上限 |
| 浏览器直连 | 面板在 `Proxy.opened`,必须经 ca 验签;身份头存在性由 gateway 不变量保证 |
| BFF 转发 | 目标仅限该 widget 所属 provider;透传 `X-Auth-User` / `X-Auth-Admin` / `Authorization`,且 strip-then-set 剥离客户端伪造值(对齐 ca 契约) |
| `widget.navigate` | url 必须同 origin(`new URL(u).origin === location.origin`),否则拒绝 |
### 5.3 日志与审计
- 拉取失败 / 超时 / 校验失败 → db2 Logs(warn 级别)
- manifest `version` 变化 → info 日志(审计"谁升级了控件")
- 明确不做内容审核:控件来自内部服务而非用户输入,信任边界在 provider 白名单处收口(与 dnfs2 用户样式节点区分,如需对用户内容把关仍走 dnfs2 的 ai_review 链路)
## 6. 目录结构(建议)
```
panel/
├── Makefile
├── _set.sh / _run.sh
├── src/
│ ├── main.cpp # libhttp HttpServer + 路由注册 + 日志
│ ├── widget_store.cpp # provider 拉取 / manifest 校验 / TTL 缓存
│ └── bff.cpp # /api/w/<id> 转发(白名单 + 身份头透传)
└── public/
├── panel.html # 背景视图 + 网格外壳
└── panel.js # 拉 /widgets → 建 iframe → postMessage 协议
```
## 7. 实现阶段
| 阶段 | 内容 | 验收 |
|---|---|---|
| M1 | 骨架:`_set.sh` / `_run.sh`、libhttp 服务、`/health`、静态面板页、widget_store 拉取 + 缓存、`/widgets`、`/widget/<id>` | curl 可见清单与子页面 |
| M2 | 页面 JS:网格渲染、iframe sandbox 加载、postMessage 协议、loading / 占位态 | 浏览器两个示例控件可显示 |
| M3 | BFF 转发 + 主题同步 + `widget.log` → db2 | 控件调通自己后端接口 |
| M4 | 安全加固审计(CSP / 沙箱 / 白名单复核)+ `tests/blackbox/test_panel.py` + 本文档定稿 | 黑盒测试通过 |
## 8. 测试(tests/blackbox/test_panel.py 思路)
- 起一个 mock provider(Python 标准库 HTTP server)提供两个 manifest
- 断言项:`/widgets` 返回清单;`/widget/<id>` 响应含 CSP `sandbox` 头与内联片段;provider 下线后 `/widgets` 状态为不可用占位;BFF 转发透传 `X-Auth-User`;超大 manifest 被拒;未列入 `--widgets` 的控件不渲染
## 9. 风险与取舍
| 取舍 | 说明 |
|---|---|
| iframe 开销 | 每个控件一个 iframe(含独立文档 + CSP),内存与加载略重;换来的是控件间样式/脚本彻底隔离,控件可独立演进 |
| 聚合单点 | 面板是控件编排的单点;控件业务能力仍分布在各自服务,面板故障只影响聚合视图,不影响各服务自身 |
| 无在线编排 | v1 布局静态声明(manifest.size + 面板清单顺序);拖拽/自定义布局留给 v2 |
## 10. 参考
- gateway 转发白名单与身份契约:[gateway/README.md](../gateway/README.md)
- 注入机制先例(js 特效 iframe 沙箱 / style 主题):[docs/dnfs_custom_themes.md](dnfs_custom_themes.md)
- 服务注册与 CLI 配置约定:[core/services.yaml](../core/services.yaml)、[ca/README.md](../ca/README.md)(strip-then-set 契约)
- HTTP 服务端库:[libhttp/README.md](../libhttp/README.md)
@@ -0,0 +1,101 @@
# actpred 动作归一化与逐列权重开发方案
> 文档类型:开发方案
> 模块:actpred(联动 pybot 数据契约)
> 作者:自动生成
> 日期:2026-08-14
> 前置实验:`pybot_actpred_dataset_training_eval_20260814.md`
---
## 1. 问题陈述
闭环评估(R4)暴露:z-score 归一化把 18 列方差拉平,隐式实现"全列等权",导致**舵机精度 ↔ spk.F 频率跟踪的权衡**——未归一化模型舵机准但 spk.F 崩(21k MSE),归一化模型 spk.F 改善 3× 但简单动作恶化 1.3-25×。
本质:**缩放方式(数值稳定性)与列重要性(优化方向)是两个正交维度**,当前被隐式耦合。方案目标:固定物理范围归一化(数值稳定、免 stats 文件)+ 显式逐列权重(舵机优先),两者解耦。
## 2. 方案设计
### 2.1 固定物理范围 min-max 归一化(actpred 侧)
位置决策:**归一化保留在 actpred 侧,pybot 继续记录原始量纲**。理由:
- **raw at rest**:episode 是数据资产,原始角度/频率可复用于逆运动学、碰撞检测等分析,归一化不可逆
- **反归一化链条**:若 pybot 侧归一化,`--loop` 执行前需反归一化,min/max 表要同时存在于 recorder 与 loop 两端;现状 `predict()` 返回原始量纲,pybot 无感知,边界更干净
- **代码库先例**:IMU 即"pybot 记录原始、actpred `imu_norm.py` 按物理量程归一化",动作走同一条路保持一致
- **pybot 零依赖承诺**:归一化逻辑入 recorder 会加深 pybot 对 numpy 的依赖面
固定范围常量表(新增 `actpred/action_norm.py`,类比 `imu_norm.py`):
| 列 | 物理范围 | 依据 |
|---|---|---|
| 0-9 舵机角 | **[0, 180]** | `pybot/hd/servo.py` `MAX_ANGLE=180.0`(校准后全行程 0-180°) |
| 10 spk.F | [0, 5000] | `@Speaker max_f=5000` |
| 11 spk.delay | [0, 2000] | 实用上限(脚本用 10-500ms) |
| 12-17 保留位 | 恒 0 | 不参与归一化,损失权重 0 |
min-max 映射:`x_norm = (x - lo) / (hi - lo)` → [0,1](或映射到 [-1,1],与 IMU 归一化输出区间一致,待实现时定)。
优点:免 action_stats.npy(消除 stats 路径类 bug 的整类风险)、零状态部署、数值区间有界。风险:min-max 对离群值敏感——若真实数据某列越界,需 clamp 到 [0,1] 并告警(防御口径与 `_action_bounds` 一致)。
可选细化(后续):按各舵机 `|max|`(s1=90°、s2=180°…)分列归一化,90° 舵机能用满 [0,1],分辨率更高;需要读取 @Servo 块的 max 列构建表。
### 2.2 逐列加权损失
```python
COLUMN_WEIGHTS = np.array([
1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, # 0-9 舵机(安全关键)
0.2, 0.2, # 10-11 spk.F / spk.delay(声音与节奏,不影响运动)
0.0, 0.0, 0.0, 0.0, 0.0, 0.0, # 12-17 保留位(恒 0,纯噪声)
], dtype=np.float32)
# loss = mean(w * (pred - target)^2) / mean(w)
```
实施点:
- `train.py` / `pretrain_mt.py` 的 `train_one_epoch` / `validate`:MSE 处乘权重向量(权重存 DataConfig,随 checkpoint 序列化,推理端无需感知)
- 权重可经 CLI `--column-weights` 覆盖(默认用上表)
- 训练集/验证集用同一权重;`evaluate.py` 的逐列误差报告不受影响(报告仍按无权重口径,便于对照)
### 2.3 归一化与权重的组合矩阵
| 配置 | 数值稳定性 | 舵机精度 | spk.F 跟踪 | 预期适用 |
|---|---|---|---|---|
| raw + 等权(模型 A) | 差(loss 数百量级) | 好 | 崩 | 仅演示/对照 |
| z-score + 等权(模型 B) | 好 | 差 | 好 | 现状 |
| **固定范围 + 加权(本方案)** | 好 | 好 | 中(w=0.2 保留梯度) | **目标** |
## 3. 配套改进(同批实施)
### 3.1 R4 评估器修正(指标失效修复)
1. **oob_rate 恒值列退化**:span<阈值(如 1e-3)的列不参与越界判定;或改用固定物理界(舵机 [0,180]、spk.F [0,5000])。推荐后者——与 §2.1 常量表共用,且"越物理界"比"越经验界"更符合安全语义
2. **error_growth 长度混叠**:按 episode 归一化后聚合(每 episode 自身首末段比值取中位数),避免短 episode 尖峰主导曲线
### 3.2 R1-B rollout + scheduled sampling
闭环误差累积源于 teacher forcing 训练与自预测推理的不匹配。已实现的 `--rollout-len K --ss-prob p` 直接对标:建议 `K=4, p=0.5`,与加权损失同批训练对照。
### 3.3 数据扩展
- **实机录制**(根本瓶颈):dry-run 帧为噪声、IMU 全零,视觉/姿态通路无真实信号。实机 `--record` 后复跑本实验
- 验证集 1 个 episode 选模噪声大:val_split 0.15→0.3
- 补充站立/保持类长序列(当前 std 类仅 1-81 步)
## 4. 实施步骤
| 步骤 | 内容 | 产出 |
|---|---|---|
| 1 | 新增 `actpred/action_norm.py`:固定范围常量表 + normalize/denormalize + clamp 防御 | 独立纯 numpy 模块 + 单测 |
| 2 | DataConfig 增 `normalize_mode`(`zscore`/`fixed_range`)与 `column_weights`;dataset/train 接入 | 训练链路支持 + 单测 |
| 3 | `evaluate.py` oob 改物理界、error_growth 按 episode 归一化 | 评估器修正 + 单测 |
| 4 | 训练对照:固定范围+加权 vs z-score+等权(同种子、同数据) | 模型 C/D checkpoint |
| 5 | 复跑 R4 闭环评估,验证权衡解耦 | 对比报告 |
步骤 1-3 可并行;步骤 4-5 依赖前置完成。预计步骤 1-3 改动集中在 `actpred/`(`action_norm.py` 新增、`config.py`/`dataset.py`/`train.py`/`evaluate.py` 小改),不影响 pybot 与部署链(归一化在推理端内部完成,`predict()` 输出仍为原始量纲)。
## 5. 决策记录
- **归一化位置**:讨论过"移入 pybot 按列 min/max 归一化"。结论:不采纳(raw at rest、反归一化链条变长、IMU 先例不一致),但采纳其"固定物理范围"内核,落在 actpred 侧常量表实现
- **舵机范围**:±180° 为误述,实际全行程 0-180°(`servo.py MAX_ANGLE=180.0`),归一化表按 [0,180]
- **spk.F 对数尺度**:备选未定。若加权后 spk.F 跟踪仍不足,再考虑 log 变换(频率感知是对数的,10→1000 跨两个数量级)
@@ -0,0 +1,95 @@
# actpred Hailo 推理速度测试报告:cv2.LUT 预处理优化验收
> 文档类型:测试报告
> 模块:actpred(hailo_infer.py cv2.LUT 快路径)
> 作者:自动生成
> 日期:2026-08-17
> 前置:`actpred_hailo_preprocess_optimization_plan_20260817.md`(优化方案,已实施)、
> `actpred_inference_speed_benchmark_20260813.md`(旧基准)
---
## 1. 测试环境
| 项 | 值 |
|---|---|
| 设备 | Raspberry Pi 5(4×A76)+ AI HAT 2(Hailo-10H,/dev/h1x-0) |
| 软件 | hailo_platform 5.3.0、opencv-python-headless 5.0.0、torch CPU 2.x |
| 模型 | actpred_v4.hef(onehot 1864 logits,fixed_range,640×480×3 帧输入) |
| 数据 | 新录 640×480 数据集 episode_0000(图像与模型输入同尺寸,免 resize) |
| 输入 | 3 帧 640×480 RGB + 5×18 动作历史(stand 姿态,spk 列 0/10 契约) |
## 2. 测试方法
- 预热 10 次后连续 200 次取均值(端到端 `predict` 调用,含预处理/绑定/NPU/解码)
- 分段计时:预处理(LUT 量化 + 绑定)/ NPU run / 后处理(反量化 + 1864 logits 解码)
- 对照:PyTorch CPU 同模型同输入(200 次均值)
## 3. 结果
### 3.1 端到端延迟
| 后端 | 延迟 | 相对优化前 |
|---|---|---|
| HEF(cv2.LUT 快路径) | **16.1 ms** | 52.4 → 16.1 ms(**3.3×**) |
| PyTorch CPU | 107.9 ms | — |
| 加速比(HEF vs CPU) | **6.7×** | 优化前 2.1× |
### 3.2 流水线分段(HEF)
| 阶段 | 耗时 | 占比 |
|---|---|---|
| 预处理(cv2.LUT + 输入绑定) | 3.0 ms | 19% |
| NPU run(HEF 纯推理) | 12.5 ms | 78% |
| 后处理(反量化 + decode) | 0.6 ms | 4% |
**瓶颈已从 Python 预处理转移到 NPU 本身**——预处理由优化前 26.5ms 降至 3.0ms(8.8×)。
### 3.3 闭环控制预算
| 控制率 | 周期预算 | HEF 占用 |
|---|---|---|
| 10 Hz | 100 ms | 16% |
| 20 Hz | 50 ms | 32% |
| 30 Hz | 33 ms | 48% |
10Hz 闭环下 NPU 仅占 16% 预算;上探 30Hz 也只占一半,余量充足。
## 4. 一致性验收
### 4.1 cv2.LUT vs float 路径输出(NPU 实测,max_diff)
| 输入类型 | max_diff |
|---|---|
| 640×480 RGB(主路径直通) | 0.0000 |
| 224×224 RGB(PIL resize) | 0.0000 |
| 全零帧 / 全 255 帧 | 0.0000 |
| 灰度 (H,W)(PIL 转 RGB) | 0.0000 |
| RGBA (H,W,4)(PIL 合成) | 0.0000 |
| 奇数尺寸 333×777(PIL resize) | 0.0000 |
| 随机帧 + 随机历史 × 8 seed | 0.0000(worst) |
### 4.2 无 cv2 回退
`HAS_CV2=False` 时 `_frame_luts=None` → float 路径,输出与 cv2 路径 max_diff = 0.0。
### 4.3 回归验收
| 项 | 结果 |
|---|---|
| `_npu_test.py` 4 角(真实/全零帧 × 真实/全零历史) | PASS(img/hist 敏感度 > 0.1) |
| HEF vs torch(原始动作空间) | 0.30°(预算 ≤1°) |
| 全套单测(test_actpred.py) | 188 tests OK |
## 5. 结论
1. cv2.LUT 快路径端到端 **16.1ms**,相比优化前 52.4ms 提升 3.3×;预处理不再是瓶颈
2. 与 float 路径**逐点一致**(7 类输入 + 8 seed 随机全部 0 差异),无隐藏分支
3. 无 cv2 环境自动回退,行为不变;量化误差(vs torch 0.30°)未回归
4. 10Hz 闭环预算占用 16%,支持控制率上探至 30Hz
## 6. 附注
- 优化前数据(52.4ms/26.5ms)来自 2026-08-17 上午 640×480 数据集基准
- cv2.LUT 的"归一化+量化"合并表数学上与 float 路径等价(0 LSB 取整差)
- 依赖:opencv-python-headless>=4.9(已入 requirements.txt;缺失仅损失加速,功能正常)
@@ -0,0 +1,96 @@
# actpred 模型 C 实验报告:固定范围归一化 + 逐列加权
> 文档类型:实验报告
> 模块:actpred
> 作者:自动生成
> 日期:2026-08-14
> 前置实验:`pybot_actpred_dataset_training_eval_20260814.md`(模型 A/B)
> 开发方案:`actpred_action_norm_weighting_plan_20260814.md`(本实验为其步骤 4)
> 后续实验:模型 D(fixed_range + 加权 + R1-B rollout),训练中
---
## 1. 实验设置
按开发方案步骤 4 训练对照模型 C:固定物理范围归一化 + 默认逐列权重。
| 项 | 值 |
|---|---|
| normalize_mode | fixed_range([0,1] min-max,`action_norm.py` 常量表) |
| column_weights | 默认:舵机 1.0 / spk 0.2 / 保留位 0.0 |
| 数据 / 种子 / 增强 | 与模型 A/B 相同(763/41 样本,seed 42,ColorJitter) |
| epochs / patience | 100 / 15 |
| checkpoint | `actpred/checkpoints/recorded_run_fixed/` |
## 2. 训练结果(teacher forcing 口径)
- 34 epochs 触发 early stopping
- **best val_loss = 0.000394**(epoch 21)——三方最优(A: 19.02 原始量纲、B: 0.0288)
- train loss 收敛至 ~0.0004-0.0008
**teacher forcing 口径下,方案目标完全达成**:loss 数值稳定(<0.001)、保留位列剔除、spk 列仍保留梯度(w=0.2)。
## 3. 闭环评估(R4)——目标失败
| 指标 | 模型 A(raw) | 模型 B(zscore) | 模型 C(fixed+加权) |
|---|---|---|---|
| overall_mse | 6446.7 | **2445.4** | 19843.5 |
| error_growth | 0.005 | 0.082 | **13.43(发散)** |
| smoothness_ratio | 0.147 | 1.047 | 3.19 |
| oob_rate(物理界) | — | 28.1% | 100% |
**teacher forcing 指标与闭环行为完全背离**:C 的训练/验证 loss 最好,闭环却首次出现发散(error_growth 13.4 > 1)。
### 3.1 逐 episode 闭环 mean_mse
| Episode | A | B | C | C 相对 B |
|---|---|---|---|---|
| 0000 stand | 25.5 | 134.0 | 52882.2 | 395× 恶化 |
| 0001 jump | 20.5 | 221.1 | 35228.6 | 159× 恶化 |
| 0002 left-hand | 21252.2 | 6849.7 | **2420.3** | 2.8× 改善 |
| 0003 left-turn | 276.0 | 641.0 | 3630.5 | 5.7× 恶化 |
| 0004 lie-down | 35.2 | 46.9 | 470.8 | 10× 恶化 |
| 0005 rght-hand | 21340.0 | 8022.9 | **3691.2** | 2.2× 改善 |
| 0007 stdd | 49.8 | 115.9 | 47180.4 | 407× 恶化 |
| 0008 t0 | 644.5 | 553.3 | 3927.6 | 7.1× 恶化 |
| 0009-0012 tilt-* | 29.8-34.2 | 59.6-751.7 | 45111-46268 | 61-775× 恶化 |
C 在 hand 类 episode(spk.F 大跨度)上**三方最优**(2.2-2.8× 优于 B),证明加权确实教好了频率跟踪;但其余 episode 全部严重发散。
## 4. 根因分析
### 4.1 逐列预测定位(episode_0000,76 步闭环滚动)
| 列 | 预测范围 | 真值 | 判定 |
|---|---|---|---|
| col 7 s8 舵机 | 1.6 → **159.0** | 0 | 舵机漂移 159° |
| col 10 spk.F | 27.6 → **1080.2** | 10 | 频率漂移 1080Hz |
| col 11 spk.delay | **-9.8** → 12.6 | 10 | 负时延越界 |
| col 12-17 保留位 | ±0.18 | 0 | 正常(排除权重 0 列失控假设) |
### 4.2 发散机制(尺度放大 + exposure bias)
1. **尺度放大**:fixed_range 下 1 个归一化单位 = spk.F 5000Hz 原始量(z-score 下各列单位方差,单位误差对应原始量级相当)。spk.F 归一化空间漂移 ±0.2 → 原始 ±1000Hz
2. **权重放松**:w=0.2 使训练对 spk.F 施加 1/5 压力,配合 28 倍尺度放大,spk.F 成为闭环最脆弱列
3. **反馈滚雪球**:漂移后的 spk.F 回写历史 → 注意力被污染 → 舵机列(col 7)跟漂 → 误差逐轮放大(episode 内 growth 3.6-430×)
4. **teacher forcing 掩盖一切**:训练/验证喂真值历史,上述回路从未出现,故 val loss 0.000394 最优而闭环最差
### 4.3 直接诱因确认
- 保留位列(权重 0)预测值正常(±0.18)——**排除**"零权重列无梯度失控"假设
- 推理端归一/反归一逻辑正确(`predict.py` fixed_range 分支逐行核对)——**排除**实现 bug
- `denormalize_action` 不 clamp("保留模型外推能力"的设计决策)放大了越界值的回传,为次要因素
## 5. 结论
1. **逐列加权方案部分有效**:spk.F 跟踪达到三方最优(hand 类 2.2-2.8× 优于 B),方案目标"频率也要跟踪"达成
2. **方案不完整**:缩放方式(fixed_range)与列权重正交解耦后,暴露了**尺度放大 × exposure bias** 的叠加风险——单步 teacher forcing 训练无法抵御闭环反馈
3. **教训**:teacher forcing val loss 不能作为方案验收指标,必须过 R4 闭环评估;本实验正是 R4 的价值证明
## 6. 后续
- **模型 D**(训练中):fixed_range + 加权 + R1-B rollout(`rollout_len=4, ss_prob=0.5`),让模型在训练期面对自预测反馈,直击 exposure bias
- 若 D 闭环仍不稳,备选路线(已在讨论中评估):
- `denormalize_action` 输出 clamp 到物理界(快速缓解)
- one-hot 离散化 + CE(对数分箱 + 软标签):消除尺度放大、内置物理界、NPU 友好;代价是序数信息与数据需求
- spk.F 对数尺度(z-score 前 log 变换)
@@ -0,0 +1,154 @@
# actpred Hailo 推理预处理优化:cv2.LUT 查表合并归一化与量化(已实施)
> 文档类型:开发方案(已实施)
> 模块:actpred(部署链 hailo_infer.py)
> 作者:自动生成
> 日期:2026-08-17(原型实测 + 集成实施)
> 前置:`actpred_inference_speed_benchmark_20260813.md`(速度基准)、
> 2026-08-17 新 640×480 数据集重测(本方案 §2 数据)
> 📌 **版本历程**
> 1. 初版:numpy 查表法为核心,预期端到端 18-20ms
> 2. 原型实测(§3.4):**numpy 高级索引查表在 Pi5 上无加速**(fancy
> indexing 的 gather 开销与逐元素 float 运算相当),预期不成立;
> 合并系数乘加仅 -5ms;**cv2.LUT 实测 15.8× 加速**(§3.4)
> 3. **已实施(§5)**:`hailo_infer.py` 集成 cv2.LUT 快路径(可选依赖,
> 无 cv2 自动回退 float 路径),端到端 52.4 → **16.0 ms**,输出与
> float 路径逐点一致(max_diff = 0)
---
## 1. 背景与动机
2026-08-17 用新录制的 640×480 数据集(免 resize 训练样本)重测 HEF 推理速度:
| 阶段 | 640×480 输入 | 224×224 输入(旧数据) |
|---|---|---|
| 端到端 predict | **52.4 ms** | 60.0 ms |
| 预处理 | **35.9 ms** | 43.1 ms |
| NPU run | 12.5 ms | 12.9 ms |
| 后处理 | 0.6 ms | 0.6 ms |
输入尺寸已与模型一致(resize 变 no-op),**预处理仍是端到端延迟的主导项**(68%)。
旧基准报告(2026-08-13)已记录同类问题:1080p 采集的预处理缩放约 45ms 主导端到端延迟,
当时通过把默认分辨率改回 640×480 缓解;本次分析证明 640×480 下 Python 端预处理
仍有约 36ms 的优化空间——但空间比初版估计小(见 §3.4 实测)。
## 2. 瓶颈分析(100 次均值分段计时)
| 子步骤 | 耗时 | 说明 |
|---|---|---|
| `_frame_float`(3 帧) | **27.2 ms** | PIL fromarray/convert + resize(同尺寸 no-op 仍有开销)+ `(x/255-mean)/std` float 运算(92 万像素×3 通道) |
| `create_bindings` + 帧量化绑定 | **9.1 ms** | `_bind_frames`:float 归一化帧 → `round(x/q.scale+q.zp)` 再量化回 uint8 |
| history/pred 绑定 | 0.07 ms | 可忽略 |
**关键冗余**:当前流程先做 float 归一化(`_frame_float`),随后又量化回 uint8(`_bind_frames`)——
中间 float 帧是纯中间态,归一化与量化可以**合并为一步**。
## 3. 优化方案
### 3.1 主方案:合并系数乘加(纯 numpy,无新依赖)
HEF 帧输入是 uint8 量化值:`q = round(((x/255 - mean)/std) / q_scale + q_zp)`。
把 4 步运算合并为**一次乘加**(系数预计算,按通道):
```
q = round(x * A[c] + B[c])
A[c] = 1 / (255 * std[c] * q_scale)
B[c] = -mean[c] / (std[c] * q_scale) + q_zp
```
- 现流程 4 次全数组运算(`/255`、`-mean`、`/std`、`/q_scale+q_zp`)→ 1 次乘加
- 数值完全等价(实测 max_diff = 0 LSB,见 §3.4)
- 直通模式(`_normalize_frames=False`)同理:`A=1/q_scale, B=q_zp`
### 3.2 附带优化(与主方案正交)
1. **免 PIL 路径**:输入已是 np.ndarray 时直接运算,跳过 `Image.fromarray/convert`
2. **resize no-op 跳过**:`image.size == (W,H)` 时不调用 `resize()`
3. **缓冲复用**:`np.multiply/np.add` 带 `out=` 参数,减少临时数组分配
### 3.3 已实施快路径:cv2.LUT(可选依赖,无 cv2 自动回退)
`cv2.LUT(img, lut)` 是 C 实现的 uint8→uint8 查表,单次遍历,无 float 中间态——
把"归一化+量化"合并为一步。集成要点(`hailo_infer.py`):
- 惰性导入 cv2(`HAS_CV2`);**无 cv2 或非逐帧输入布局(ncfhw/nhwc_fused)自动回退 float 路径**
- 加载时按每帧 `quant_info` 构建 256 项表(归一化/直通两种,`_normalize_frames` 判定)
- **OpenCV 5.0 的 cv2.LUT 只接受单通道表**((256,3) 会断言失败),逐通道应用后 `cv2.merge`
- 输入尺寸与模型不一致时先 PIL resize(仅此情况走 PIL)
- 依赖安装:`opencv-python-headless`(5.0.0,清华镜像;官方源过慢)
- 注:本方案实测后发现**合并系数乘加(§3.1)未实施**——cv2.LUT 收益已远超,
避免冗余代码;§3.1 保留作为无 cv2 环境下的潜在备选
### 3.4 原型实测评估(2026-08-17,100-200 次均值,640×480×3 帧)
| 方案 | 3 帧耗时 | 对比现 float 28.5ms | 数值差异 |
|---|---|---|---|
| 现 float 路径 | 28.5 ms | 基线 | — |
| numpy 高级索引查表 | 26.6 ms | **无加速(1.0x)** | 0 LSB |
| 合并系数乘加 | 23.2 ms | 快 19%(-5ms) | 0 LSB |
| **cv2.LUT(逐通道)** | **1.68 ms** | **快 15.8×** | **0 LSB** |
**结论**:
- numpy fancy indexing 的 gather 开销与逐元素 float 运算相当,查表法**无收益**
- 纯 numpy 可达的最佳是合并乘加(-5ms),受限于 92 万像素 float32 数组的内存吞吐
- cv2.LUT 的 C 查表把帧预处理降一个数量级,且数值完全等价(0 LSB)
### 3.5 实测收益(已实施,集成后 NPU 实测)
| 指标 | 优化前 | 优化后(cv2.LUT) |
|---|---|---|
| 帧预处理(3 帧) | 26.5 ms | **3.0 ms(分段实测含绑定)** |
| 端到端 predict | 52.4 ms | **16.1 ms(3.3×)**(200 次均值,见测试报告) |
| 输出一致性 | — | 与 float 路径 max_diff = **0.0000**(7 类输入 + 8 seed 随机) |
| HEF vs torch | 0.50° | 0.30°(4 角复验 PASS) |
| vs PyTorch CPU | 2.1× | **6.7×**(16.1 vs 107.9 ms) |
闭环 10Hz(100ms 预算)占用率 52% → **16%**,20Hz 32%,30Hz(33ms)48%。
> 完整验收数据见 `actpred_cv2_lut_benchmark_report_20260817.md`(测试报告)。
## 4. 一致性约束(关键)
- **训练侧不动**:`dataset.py` 的 `Resize+ToTensor+Normalize` 保持 float 路径——
合并乘加只是推理侧对"归一化+量化"的合并实现,数值等价(实测 0 LSB)
- **三后端口径**:`predict.py`(PyTorch,float 输入不量化)、`onnx_infer.py`(ORT,float)
不受影响;优化仅作用于 `hailo_infer.py` 的 uint8 量化输入路径
- **帧输入判定不变**:`_normalize_frames`(量化网格负零偏判定)决定走
归一化还是直通,逻辑与现状一致
- 旧 5 输入 / 旧版单图像 HEF 布局同样适用(按各自 quant_info 算系数)
## 5. 实施步骤(已完成 ✅)
| 步骤 | 内容 | 状态 |
|---|---|---|
| 1 | `hailo_infer.py`:加载时按每帧 quant_info 构建 256 项 LUT(归一化/直通两种) | ✅ `_build_frame_luts` |
| 2 | `predict` 的 nhwc_frames 分支走 cv2.LUT 快路径(尺寸不匹配时 PIL resize);无 cv2 回退 float | ✅ `_frame_lut_quant` + `HAS_CV2` |
| 3 | NPU 实测:640×480 数据集 100 次均值 | ✅ 16.0 ms(52.4 → 16.0) |
| 4 | `_npu_test.py` 4 角 + HEF vs torch 对比复验 | ✅ PASS,0.30°(≤1° 预算) |
| 5 | 全套单测(TestHailoPredictor 等) | ✅ 188 tests OK |
| 6 | 依赖安装:opencv-python-headless 5.0.0(清华镜像) | ✅ venv 已装 |
## 6. 风险与对策
| 风险 | 对策 |
|---|---|
| 查表取整与 float 路径存在差异 | 实测 0 LSB(表项 float32 与现路径同精度);NPU 输出 vs float 路径 max_diff=0,vs torch 0.30° |
| 无 cv2 环境 | 惰性导入 + 自动回退 float 路径,行为与优化前完全一致 |
| OpenCV 5.0 多通道 LUT 断言失败 | 逐通道单表 + `cv2.merge`(实测无性能损失) |
| 非 nhwc_frames 布局(旧 HEF) | 仅该布局启用 cv2 路径,其余走原 float 路径 |
| 未来输入尺寸变更 | LUT 与尺寸无关(逐像素映射);尺寸不一致时才走 PIL resize |
## 7. 决策记录
- **查表法(初版核心)实测证伪**:numpy 高级索引 gather 在 Pi5 上与逐元素 float
运算同速(26.6 vs 28.5ms),"查表降为内存索引即快"的假设不成立——教训:
性能方案须原型实测,不能凭直觉估计
- **最终方案选 cv2.LUT**:C 实现查表把帧预处理从 26.5ms 降到 1.7ms(15.8×),
端到端 52.4 → 16.0ms;数值 0 LSB 等价。合并系数乘加(-5ms)收益被碾压,
不再实施(§3.1 保留为无 cv2 环境的备选)
- **依赖取舍**:opencv-python-headless 作为可选依赖(惰性导入 + 自动回退),
有 cv2 环境收益 3.3×,无 cv2 环境行为不变——收益远大于依赖成本
- **训练侧不改**:训练用 float 归一化(增强/数值精度),推理侧查表实现——两侧
口径在"归一化值"层面一致,量化差异由部署链固有预算吸收(HEF vs torch 0.30° 余量充足)
@@ -0,0 +1,82 @@
# actpred 多头 one-hot 分类输出开发方案
> 文档类型:开发方案
> 模块:actpred(联动 pybot 数据契约)
> 作者:自动生成
> 日期:2026-08-14
> 前置实验:`pybot_actpred_dataset_training_eval_20260814.md`、
> `actpred_fixed_range_weighted_experiment_20260814.md`(模型 A-E 系列)
---
## 1. 背景与动机
模型 A-E 系列实验定位到两个结构性难题:
1. **尺度放大**:fixed_range 线性 min-max 下 spk.F 的 1 个归一化单位 = 5000Hz 原始量,闭环反馈中误差滚雪球(模型 C/D 发散根因之一)
2. **精度错配**:常用值(10Hz → 0.002)挤在线性尺度的低端,[0,1] 精度预算被浪费(log_range 正在验证的对数尺度解法)
多头 one-hot 是本问题的**结构性解法**:把 18 维连续回归换成 18 组独立分类头,每组输出一个 one-hot 分布。分类天然消除尺度放大(每箱等距)与物理界问题(箱只覆盖物理范围),且多列 softmax 是 NPU(Hailo)最擅长的计算形态。
## 2. 方案设计
### 2.1 分箱设计(每组 one-hot 的维度)
| 列组 | 分箱方式 | 箱数/列 | 依据 |
|---|---|---|---|
| 舵机 0-9(10 列) | 均匀 **1°/箱** | 180 | 匹配数据粒度:微动作插值 0.75°/步,5° 箱会把相邻步量化进同一箱、毁掉插值结构 |
| spk.F(10) | 对数分箱 | 32 | 频率对数感知,低端聚集;log10(1+x) 均匀切 |
| spk.delay(11) | 对数分箱 | 32 | 同左 |
| 保留位 12-17 | 无头 | 0 | 恒 0,不参与 |
**输出总量**:10×180 + 32 + 32 = **1864 logits**(当前 18)。最后层 128→1864 ≈ 238K 参数,模型 409K → ~650K,仍属轻量。
### 2.2 各层改动
| 文件 | 改动 |
|---|---|
| `actpred/bins.py`(新增) | 分箱表 + `encode(x)→one-hot` / `decode(p)→连续值`(含对数箱);纯 numpy,供推理/部署复用 |
| `model.py` | PredictionHead 输出 1864 维 → reshape (B, 18, bins) → 逐组 softmax |
| `dataset.py` | target 从 (B,18) 连续值 → 每组 one-hot;**软标签**:真值高斯扩散到 ±1-2 箱 |
| `train.py` | 损失 = Σ 列权重 × CE(组输出, one-hot 真值);列权重沿用 default_column_weights |
| `predict.py` | 输出 decode:**期望值** `Σ pᵢ·箱中心`(推荐)或 argmax 箱中心 → 18 维原始量纲 |
| `evaluate.py` | 无感——predict 返回仍是 18 维原始量纲 |
| `export_deploy` / Hailo | 多组 softmax 为 NPU 友好形态 |
### 2.3 解码策略(执行时如何取连续值)
| 方式 | 行为 | 评估 |
|---|---|---|
| **期望值**(推荐) | `Σ pᵢ × 箱中心` | 平滑连续,分辨率超越箱宽(1° 箱可出 46.4°);单峰假设 |
| argmax | 取最大概率箱中心 | 确定性,量化到 1° |
| 按分布采样 | 随机抽箱 | 探索用,后续离线 RL 可复用 |
## 3. 关键决策点与理由
1. **舵机 1°/箱而非 5°/箱**:录制数据的微动作插值粒度为 0.75°,箱宽必须匹配数据粒度;5° 箱会把 45.0°/45.75° 压进同一箱,模型学不到特意录制的插值结构
2. **软标签**:804 样本对 1864 logits 偏少,多数箱极少被访问。真值 one-hot 高斯扩散到邻近箱,未访问箱也能获得梯度
3. **期望值解码补序数信息**:one-hot 丢失"45° 和 46° 相邻"先验,期望值解码在推理端部分恢复;训练端靠软标签缓解
4. **pybot 契约零变化**:predict 输出仍是 18 维原始量纲,闭环与录制数据无感知
5. **与现有归一化链路的兼容**:one-hot 方案独立于 zscore/fixed_range/log_range——分箱编码替代归一化,不叠加使用
## 4. 实施步骤
| 步骤 | 内容 | 产出 |
|---|---|---|
| 1 | `bins.py`:分箱表 + encode/decode + 对数箱 + 软标签工具 | 独立纯 numpy 模块 + 单测 |
| 2 | `dataset.py`:目标 one-hot 化(软标签可开关) | 数据链路 + 单测 |
| 3 | `model.py` + `train.py`:多头 softmax + 加权 CE | 模型与训练 + 单测 |
| 4 | `predict.py`:期望值/argmax 解码集成 + 冒烟 | 推理链路 + 单测 |
| 5 | 训练模型 F,与 A-E 闭环五方对比 | 对比报告 |
## 5. 与其他方案的定位
- **模型 E(log_range)正在训练**:若对数尺度已解决精度错配与闭环发散,one-hot 降级为可选优化(分类头天然有界、免归一化常量表的优势仍在)
- 若 E 仍失败:one-hot 是下一个强候选(结构性消除尺度放大)
- **互补关系**:one-hot 方案与逐列权重、R1-B rollout 正交,可叠加(rollout 治 exposure bias 的结论不变,任何损失形式都需要它)
## 6. 决策记录
- **箱宽**:初拟 5°/箱(保守,顾虑数据量),讨论后定为 1°/箱——数据插值粒度 0.75° 决定箱宽下限;spk 两列对数 32 箱
- **单头多选 vs 多头单选**:softmax 单头在数学上是单选择分布;"多组 one-hot" = 18 个独立 softmax 头并行,每列一个选择
- **多峰分布**:当前数据单峰为主,期望值解码够用;若未来出现双峰(如跨脚本姿态冲突),升级为逐箱 sigmoid 或 MDN
@@ -130,6 +130,52 @@ actpred 的状态表征——模型对自身俯仰/横滚/角速度是盲的。
**R8 的洞察**:离线 RL 正好契合"数据已有 + 不能实机探索"的现状。但前提是 R9 的奖励信号。 **R8 的洞察**:离线 RL 正好契合"数据已有 + 不能实机探索"的现状。但前提是 R9 的奖励信号。
### R8 细化:预训练 → 监督微调 → 离线 RL 三层流水线
**流水线形态**(基于 2026-08 实验进展的定稿):
```
阶段 0 预训练(.mt 动作动力学) → 已有(pretrain_mt)
阶段 1 监督微调(录制 episode,MSE/CE)→ 已有(train.py)
阶段 2 离线 RL(录制数据 + IMU 奖励) → 待建(本细化)
```
**为什么离线而非在线**:在线探索在真机上有物理风险(舵机乱动、碰撞),且 R4 已证明闭环
反馈敏感——RL 探索的随机扰动会放大不稳定。离线 RL 从固定数据集学策略,零硬件风险。
**算法选型**:
| 算法 | 形态 | 适配度 |
|------|------|--------|
| IQL | 隐式 Q 学习,数据高效 | 首选:单策略、录制数据规模小 |
| CQL | 保守 Q 惩罚,抗分布外 | 备选:数据质量差时更稳 |
| 决策 Transformer(DT) | 回报条件化,无价值网络 | 简替:实现最简,但回报需已知 |
**IMU 奖励工程(R9 落地)**——传感器从"输入"变"监督信号":
| 奖励分量 | 公式(示意) | 物理意义 |
|---------|-------------|---------|
| 姿态稳定 | r₁ = -α·(|roll| + |pitch|) | 越正越接近站直 |
| 抖动惩罚 | r₂ = -β·var(角加速度) | 平滑运动,节能 |
| 动作平滑 | r₃ = -γ·Σ‖aₜ - aₜ₋₁‖ | 与 R4 平滑度指标同源 |
| 任务进度 | r₄ = 姿态变化率(完成检测) | 驱动任务完成 |
奖励为标量加权和:R = Σwᵢrᵢ。设计难点在于权重标定——需实机 IMU 统计
(静止 vs 动作时的量级差)确定 α/β/γ。
**落地路径**:
```
阶段 A(数据前提): 实机 --record 采集 + IMU 统计标定奖励权重
阶段 B(离线 RL): IQL 在录制数据上训练,R4 闭环对比监督基线
阶段 C(安全在线): 小幅度动作扰动微调 + 动作界/速率保护
```
**与当前实验的衔接**:one-hot 方案(F 系列)的固定列机制与有界输出天然
兼容 RL 的动作扰动保护;样本级验证集划分(20260815)为 RL 的价值函数
评估提供更稳的口径。R8 的前提不变:奖励信号(R9)与真实数据缺一不可——
当前 dry-run 数据(噪声帧 + 零 IMU)对 RL 无意义。
### Tier 3 · 远景(与 aibox 联动) ### Tier 3 · 远景(与 aibox 联动)
- **R11 语言条件策略**:aibox 高层语言指令("向前走""左转")作为条件,actpred 出低层动作—— - **R11 语言条件策略**:aibox 高层语言指令("向前走""左转")作为条件,actpred 出低层动作——
@@ -0,0 +1,59 @@
# actpred 验证集划分方式实验报告:整集 vs 样本级随机
> 文档类型:实验报告
> 模块:actpred
> 作者:自动生成
> 日期:2026-08-15
> 前置:`actpred_zscore_baseline_20260815.md`(模型 B 基线)、
> `actpred_onehot_multihead_dev_plan_20260814.md`(one-hot 方案)
---
## 1. 问题
模型 F/F2 系列(one-hot,v2 数据)出现**过拟合签名**:train loss 持续下降(2.40→1.57)而 val loss 平台后上行(1.73→2.09),且 val 波动剧烈(区间 1.74-2.0)。
排查后发现**选模信号本身有缺陷**:
- 旧划分方式(`split_episodes`)按 **episode 整集划分**:val_split=0.15 × 12 集 → 验证集只有 **1 个 episode、60 个样本**
- 60 个样本全来自同一集(tilt 类),val loss 主要反映"这一集的难度"而非泛化能力
- 波动大 + 分布偏 → early stopping 的 best 模型选择被噪声主导
## 2. 方案:样本级随机划分
新增 `--val-split-mode {episode, sample}`(**新默认 sample**):
- `dataset.py`:`ActionDataset` 新增 `sample_indices` 参数(构建后按索引过滤);**zscore 统计量仍只由训练子集计算**(经 stats 文件复用给验证集,统计口径正确性不变)
- `train.py`:sample 模式下先建全量数据集取样本索引,按 seed 42 随机排列,取 val_ratio 为验证集
- 效果:验证集从 60 → **142 样本**(810 train / 142 val),跨全部 12 个 episode 混合
## 3. 结果对比(同配置:onehot + spk 固定 + v2 数据)
| | F2(整集划分) | F2b(样本级划分) |
|---|---|---|
| 验证集 | 60 样本(1 集) | 142 样本(12 集混合) |
| val 起点 | 1.914 | 2.056(更难、更有代表性) |
| 最优 | 1.737(epoch 7) | **1.695(epoch 5,仍在下降)** |
| 收敛形态 | 平台后上行(过拟合签名) | 平滑连续下降 |
| val 波动 | ±0.15(1.74-2.0) | ±0.05(无跳变) |
F2b 在更难的代表性验证集上取得**更低的 val loss**,且曲线平滑无异常跳变——说明 F2 的"过拟合"判断部分被验证集噪声误导,真实模型能力被低估。
## 4. 已知代价(诚实记录)
样本级划分**略乐观**:微动作插值使相邻样本高度相关(0.75°/步),随机划分后每个验证样本的近邻大量在训练集,val 会略高估泛化。适用场景:
- **选模/早停**(本次用途):噪声小、跨集混合 → 更可靠的 best 选择信号 ✓
- **能力评估**(需保守口径):仍建议整集划分或 leave-one-out,避免乐观偏差
## 5. 实施细节
- `dataset.py::ActionDataset(sample_indices=...)`:样本按索引过滤,位置在样本构建后、统计量计算前
- 签名顺序调整:`sample_indices` 置于 `frame_len` 之后,避免破坏既有位置参数调用(曾因插入位置错误导致 frame_len 错位 TypeError)
- `config.py`:`TrainConfig.val_split_mode`(默认 "sample")+ CLI + 校验
- 测试:+2(样本过滤与统计口径、CLI 映射),全套 185 通过
## 6. 后续
- F2b 训练完成后跑闭环评估,验证 onehot + spk 固定 + 新划分的实际闭环收益
- 若需严格能力评估,增加 `--val-split-mode episode` 复跑对照,或 leave-one-out 交叉验证
@@ -0,0 +1,91 @@
# actpred 模型 B 方案文档——zscore 等权稳定基线
> 文档类型:方案文档
> 模块:actpred
> 作者:自动生成
> 日期:2026-08-15
> 相关实验:`pybot_actpred_dataset_training_eval_20260814.md`(A/B 对照)、
> `actpred_fixed_range_weighted_experiment_20260814.md`(C/D/E 失败系列)、
> `actpred_onehot_multihead_dev_plan_20260814.md`(F 系列结构性方案)
---
## 1. 定位
模型 B 是六模型闭环评估(A/B/C/D/E/F)中**唯一稳定**的配置:overall_mse 2445、error_growth 0.082,远优于其余五个(6447~968459)。本文档固化其完整方案,作为:
1. 部署与实机测试的**首选基线**
2. 后续所有实验的**对照基准**
3. 归一化系列失败教训的**参照系**
## 2. 完整配置(checkpoint 实读)
来源:`actpred/checkpoints/recorded_run_norm/best_inference.pt`(epoch 26 早停,best val=0.0288)
### 2.1 数据侧(DataConfig)
| 项 | 值 |
|---|---|
| 分辨率 | 640×480 |
| action_dim / history_len / frame_len | 18 / 5 / 3 |
| normalize_actions | **True(z-score)** |
| action_stats_path | `action_stats.npy`(272B,逐列 (2,18) mean/std,训练集统计) |
| imu_dim | 0 |
| column_weights | None(**等权**) |
### 2.2 训练侧(TrainConfig)
| 项 | 值 |
|---|---|
| 数据 | v1(`data/pybot_record`,804 样本,12/1 episode 划分,seed 42) |
| 训练范式 | **单步 teacher forcing**(rollout_len=1, ss_prob=1.0) |
| 增强 | ColorJitter(--augment) |
| batch / lr / patience | 4 / 1e-3 / 15 |
| predict_delta | False |
### 2.3 模型侧(ModelConfig)
SimpleCNN(128) → Transformer(d=128, 4 头, 2 层, pre-LN) → MLP 头。409K 参数。
### 2.4 闭环表现(v1 数据,R4)
- overall_mse **2445.4**,error_growth **0.082**(<1 无发散),oob_rate 28.1%
- 逐 episode:stand 134 / jump 221 / hand 6850·8023 / tilt 60-752(唯一全部 episode 不爆表的模型)
## 3. 为什么 zscore 稳定(失败系列的对照结论)
| 方案 | 操作点 | 闭环行为 | 机理 |
|---|---|---|---|
| raw 等权(A) | 原始量纲 | 稳定但 spk.F 崩 | 舵机误差数值占优,spk.F 被忽略 |
| **zscore(B)** | **数据均值 0** | **稳定** | **各列单位方差,归一化误差等量纲;误差收缩** |
| fixed_range(C/D) | [0,1] 低端聚集 | 发散(growth 13.4/3.4) | 低端精度错配:小绝对误差=大相对误差 |
| log_range(E) | log 空间 | 爆炸(growth 157) | 指数反变换在高端放大:0.81→1.0 = 1000→5000Hz |
| onehot(F) | 箱(有界) | 部分稳定(oob=0,MSE 33K) | 有界生效,但 spk 无监督头闭环污染 |
**结论**:反馈回路需要**误差有界且收缩**的映射。z-score 的零均值单位方差恰好满足;固定范围类方案各自引入一端(低/高)的放大;one-hot 的有界性正确但需配 spk 列固定(见 F2)。
## 4. 已知局限
1. **依赖 stats 文件**:推理端需随 checkpoint 携带 action_stats.npy(与 fixed_range/onehot 的零状态部署相反)
2. **训练于 v1 数据**:delay 语义混杂(首行 200ms 未分解),spk.delay 权重 0.2——两者已被后续修复(v2 均匀数据 + delay 权重 0)
3. **spk.F 仍弱**:hand 类 episode 8023/6850 的 MSE 主导源是 spk.F(10→1000Hz 大跨度,等权下被均值化)
## 5. 演进建议:B2
模型 B 的两处已知缺陷(v1 数据、delay 权重)都已有修复就绪,B2 = **zscore + v2 数据 + 新权重(spk 列 0)**:
- 预期:闭环 MSE 进一步下降(delay 垃圾不再进历史)
- 若 B2 稳定,作为部署基线导出 ONNX/HEF
- 若 B2 需要 spk.F 频率跟踪(未来语音反馈需求),再叠加加权或 one-hot 方案
## 6. 复现命令
```bash
# 模型 B(v1 数据,旧默认权重)
./venv/bin/python train.py --data-dir /opt/micro_servers/data/pybot_record \
--checkpoint-dir checkpoints/recorded_run_norm --augment --epochs 100
# B2(v2 数据,新权重:spk 两列 0)
./venv/bin/python train.py --data-dir /opt/micro_servers/data/pybot_record_v2 \
--checkpoint-dir checkpoints/recorded_run_norm_v2 --augment --epochs 100
```
+437
View File
@@ -0,0 +1,437 @@
# aibox 模块安全与质量审计报告
- **日期**:2026-08-17(第一轮)/ 2026-08-18(第二轮复审)/ 2026-08-18(第三轮:遗留复核 + v3.4 专审)/ 2026-08-18(第四轮:3 路并行审读 + Batch H 修复)
- **审计基线**:第一轮 HEAD `ce142fe`(Batch A–D 后)→ 第二轮 HEAD `177a9ac`(Batch E 修复后)→ 第三轮 HEAD `aa15841`(L4a/b/c 落地后)→ 第四轮 HEAD `d1f780e`(Batch G + SOCKS5 验证后)
- **审计范围**:`aibox.py`(~3310 行)+ `llm_client.py` + `tools/`(7 文件)+ `subsystems/`(6 文件)+ `common/`(5 文件)
- **方法**:第一轮逐文件代码审读 + PoC 动态验证;第二轮 Batch E 修复复审 + 全量重审 + 6 处关键发现实证抽查;第三轮遗留项复核 + v3.4 专审;第四轮 3 路并行审读 + 主审逐条复核 + Linux PoC 实证(6/6)
---
## 一、总体结论
**第一轮**(基线 ce142fe):架构设计(分层、参数化 SQL、密钥隔离、消息回滚)整体健康,但**命令执行链存在一条系统性断裂**:实际运行路径绕过了精心设计的执行器,导致 1MB 硬约束、超时、中断三项保护同时失效。
**第二轮**(基线 177a9ac):Batch E 五项修复(H1/H2/H3/M1/M2)中四项确认修复到位(PoC 5/5 验证),**M2 修复不完整**(超时路径跳过工具属性清理,僵尸输出问题仍存)。第二轮全量重审新发现 **1 高危 + 9 中危 + 若干低危**,其中三项与 Batch E 修复直接相关(reader 线程泄漏 / 子代理超时盲区 / M2 残留),其余为存量问题(代理密码明文落盘、drain 线程泄漏、事件循环阻塞等)。详见"八、第二轮复审"。
---
## 二、高危发现(3 项)
### H1:`run_command` 实际路径绕过注入的 executor——三重保护同时失效
证据链:
- `aibox.py:1012` 将 `command_executor` 注入 `CommandLineTool`
- 但 `tools/command_tool.py:86-219` 的 `execute_streaming` 自带一套 **150 行的重复 Popen 实现**,`self.executor` 全程未使用(死注入)
- `tools/registry.py:71-86` 检测到 `execute_streaming` 即走该路径——**生产 100% 命中**
后果:
| 保护 | 设计位置 | 实际状态 |
|---|---|---|
| 1MB 输出上限(硬约束) | `executors.py:69` | **失效**——command_tool 的 `output_lines` 无限累积 |
| `cancel()` 中断 | `executors.py:76-86` 只 kill `_current_process` | **失效**——实际进程引用只在 command_tool 局部变量,三层中断的第三层对 run_command 不生效 |
| 超时/截断统一语义 | executors | 旁路(见 H2) |
附带问题:`msvcrt.kbhit()` 检查的是**当前进程**键盘而非子管道(`command_tool.py:123-125` 无意义代码);循环内重复 `import select`。
### H2:超时机制对无输出命令完全失效(PoC 已证)
`readline()` 是阻塞调用,超时检查只在**两次 readline 之间**执行。无输出命令会让 readline 阻塞到进程自然退出:
```
PoC:timeout=3s,命令 powershell Start-Sleep 15(无 stdout 输出)
结果:readline blocked 15.2s (timeout was 3s)
VERDICT: timeout mechanism ineffective for silent commands: True
```
两处同病:`command_tool.py:106-165` 与 `executors.py:97-113`。`--once` 模式有 `--once-timeout` 兜底,**WebSocket 模式无兜底**。
### H3:`stdin=subprocess.PIPE` 从不写入也不关闭——死锁放大器
`executors.py:156`、`command_tool.py:93`:等待 stdin 的命令(`cat` 无参、交互式 `python`、`ssh`)永不退出 → 与 H2 叠加:**readline 永久阻塞 + 超时检测永不执行 + cancel 杀不到进程** = 工具线程永久挂起。应为 `stdin=subprocess.DEVNULL`。
---
## 三、中危发现(4 项)
| # | 问题 | 位置 | 说明 |
|---|---|---|---|
| M1 | **heredoc 定界符注入** | `basic_tools.py:179` | `content` 含独立行 `AIBOX_EOF` 时 heredoc 提前终止,**后续行成为 shell 命令执行**。AI 写文件内容是不可信输入(可被网页内容/用户消息诱导)。修复:定界符唯一化(检测冲突则换 `AIBOX_EOF_2` 递增) |
| M2 | 工具流式转发循环可永久挂起 | `registry.py:90-107` | `while thread.is_alive() or not empty()` + `get(0.1)`——H3 场景下永不退出;`join(5)` 超时后返回但 daemon 线程继续向输出队列写(僵尸输出混入后续对话流) |
| M3 | web 工具无协议/目标校验 | `basic_tools.py:315-338` | `url` 未限制 scheme,`curl file:///etc/shadow`、内网地址均可达(SSRF)。本地单用户产品风险有限,建议白名单 http/https |
| M4 | shlex.quote POSIX 转义在 Windows 失效 | `basic_tools.py:99-112` 等全部指引命令 | 单引号在 cmd.exe 无效,Windows 下 `head/sed/grep` 指引命令执行异常。Linux 部署(树莓派)不受影响 |
---
## 四、低危发现(3 项)
| # | 问题 | 位置 |
|---|---|---|
| L1 | `get_pending_memos` 的 `LIMIT {limit}` f-string 拼接 | `db.py:417`——当前调用链 limit 均为内部 int,无外部直达;防御性参数化即可 |
| L2 | `check_path_safety` 仅查大小,无路径边界 | `base.py:96-110`——by design(AI 可全盘读写,本地单用户),已确认设计意图 |
| L3 | `executors.execute()` finally kill 后未 `wait()` | `executors.py:205-207`——Windows 句柄短暂滞留 |
**做得好的**:SQL 全面参数化(LIKE 通配符正确转义 `db.py:200-209`)、API key 零日志泄漏、WAL 已启用、消息回滚/tool 配对修复、`_msg_lock`/`_process_lock` 使用正确、工具实例按连接隔离。
---
## 五、架构合规矩阵
| 硬约束 | 状态 |
|---|---|
| WebSocket :8765 | ✅ |
| 依赖单向 `common←subsystems←tools←aibox` | ✅ |
| Worker 仅 7 执行工具 | ✅ |
| 工具输出 ≤1MB + 超时 + 中断 | ❌ **H1/H2/H3 三项全破** |
| prompt 三级排序加载 / sqlite 线程安全 | ✅ |
---
## 六、修复建议(一批搞定 H1/H2/H3,约 ±120 行)
1. **删除 command_tool.py 的重复 Popen 实现**,`execute_streaming` 改为一行委托 `self.executor.execute_streaming(output_queue, ...)` → 1MB、cancel、统一语义全部自动恢复
2. **executors.py 读取循环改线程化**:后台 reader 线程做阻塞 `readline` 入队列,主循环带超时轮询队列——跨平台解决 H2
3. `stdin=subprocess.DEVNULL`(H3)
4. M1 定界符唯一化 + M2 转发循环加总超时,顺手可修
---
## 七、修复落地(Batch E,2026-08-17)
五项修复(H1/H2/H3/M1/M2)已全部实施并通过验证:
| # | 修复 | 位置 | 状态 |
|---|---|---|---|
| H1 | `CommandLineTool.execute_streaming` 改为纯委托 `executor.execute_streaming`,删除 150 行重复 Popen(-165 行) | `tools/command_tool.py` | ✅ |
| H2 | `_read_with_limit` 线程化:后台 reader 线程阻塞 `readline` 入队,主循环 0.1s 轮询 + 超时/1MB 检查;shell 已死但 reader 无 EOF 时 1s 宽限排空(Windows shell=True 孙进程持管道场景) | `subsystems/executors.py` | ✅ |
| H3 | 两处 Popen 均改 `stdin=subprocess.DEVNULL` | `subsystems/executors.py` | ✅ |
| M1 | heredoc 定界符唯一化:content 含独立行与 `AIBOX_EOF` 相同时递增 `_X` 后缀直到唯一 | `tools/basic_tools.py` | ✅ |
| M2 | registry 转发循环加 3600s 总超时兜底,超时后放弃等待并返回错误信息 | `tools/registry.py` | ✅ |
### PoC 回归验证(`aibox/tests/_poc_audit_regression.py`,5/5 PASS)
```
[PASS] PoC-1 无输出命令超时(H2): 3.0s 返回(修复前阻塞15s+),结果含超时: True
[PASS] PoC-2 stdin等待命令不挂起(H3): 0.2s 返回(修复前永久挂起)
[PASS] PoC-3 1MB输出截断: 0.2s,结果 1.00MB,含截断标记: True
[PASS] PoC-4 cancel中断: cancel 后 3.0s 结束(修复前杀不到进程需等满120s)
[PASS] PoC-5 工具层1MB贯通(H1): 0.2s,工具层结果 1.00MB(修复前无限累积)
```
PoC-2 说明:`stdin=DEVNULL` 下等待输入的命令立即收到 EOF 快速退出(0.2s),优于按超时返回;判定标准为"不永久挂起"。
### 单元测试
aibox 全量单测 **264 passed**(零回归)。
### 遗留项(本轮未处理)
- M3(web 工具 scheme/目标校验)、M4(Windows 下 shlex.quote 转义失效)、L1/L3(防御性加固)——低优先级,待后续批次
- 架构合规矩阵"工具输出 ≤1MB + 超时 + 中断"一项修复后为 ✅
---
## 八、第二轮复审(2026-08-18,HEAD `177a9ac`)
方法:Batch E 修复质量复审 + 全量重审(3 路并行审读 + 主审对 6 处关键发现逐一实证抽查)。
### 8.1 Batch E 修复复审结论
| # | 修复 | 复审结论 |
|---|---|---|
| H1 | command_tool 纯委托 executor | ✅ 到位(PoC-5 验证 1MB 贯通) |
| H2 | reader 线程 + 超时轮询 | ✅ 到位(PoC-1 验证),**但有两个残余**:N4 reader 线程泄漏、N5 子代理超时盲区 |
| H3 | stdin=DEVNULL | ✅ 到位(PoC-2 验证) |
| M1 | heredoc 定界符唯一化 | ✅ 到位 |
| M2 | registry 转发总超时 | ⚠️ **不完整**(见 N6) |
### 8.2 第一轮遗留项状态
| # | 问题 | 状态 |
|---|---|---|
| M3 | web 工具无 scheme 校验 | 未修复(实证:`basic_tools.py:319+` url 未限制,`file://` 等可达) |
| M4 | shlex.quote POSIX 转义 Windows 失效 | 未修复(实证:`sed -r`/`grep -oP` 指引,Linux 部署不受影响) |
| L1 | `LIMIT {limit}` f-string | 未修复,且**新发现同型第二处**(`db.py:417` 与 `db.py:459` `get_immediate_memos`) |
| L2 | check_path_safety 无路径边界 | 维持 by design(本地单用户,AI 可全盘读写为设计意图) |
| L3 | execute() finally kill 后未 wait() | 未修复(实证:`executors.py:236-241` 无 wait;`cancel()` 同样无 wait) |
### 8.3 新发现——高危(1 项)
#### N1:SOCKS5 代理密码明文持久化到配置文件
`common/config.py:238-251`(实证):`SetProxyTool.execute` 默认 `save=True` → `set_proxy` 将 `password` 明文写入 `_config["proxy"]["socks5"]` → `_save_config` 用 `json.dump` 全量落盘 `~/.aibox/config.json`。与 api_key 走环境变量的隔离设计形成反差,任何能读该文件的进程可获取代理凭据。
修复建议:密码不落盘(仅内存)或走系统凭据库;必须落盘时对称加密 + `os.chmod(0o600)`;`save` 默认改 `False`。
### 8.4 新发现——中危(9 项)
| # | 问题 | 位置 | 说明 |
|---|---|---|---|
| N2 | drain_thread 每次工具调用泄漏一个永不退出的线程 | `aibox.py:1232-1243`(实证) | 循环条件只查 cancel 事件不查队列空,注释与实现不符;闭包持 output_callback(含 websocket)阻碍 GC;长期运行累积线程/内存 |
| N3 | 同步 threading.RLock 在 async 协程中阻塞事件循环 | `aibox.py:1855-1902, 2275, 2376` | `/new` 的 2s 轮询竞态窗口后仍可能 `with self._msg_lock:` 冻结整个事件循环 → 服务假死 |
| N4 | reader 线程泄漏(H2 残余) | `subsystems/executors.py:130-144` | 宽限 1s break 后 reader 仍阻塞 readline(Windows shell=True 孙进程持管道),stdout 未 close、reader 未 join,常驻服务累积 fd 与线程 |
| N5 | --once 超时对子代理完全无效(H2 盲区) | `aibox.py:3052-3058, 638-649` | Timer 到点只 `manager.cancel()` 父代理;子代理独立 `_cancel_event`/独立 executor/独立 HTTP client,cancel 不传播,进程挂起至子代理自行结束 |
| N6 | registry 超时路径跳过工具属性清理(M2 残余) | `tools/registry.py:99, 116`(实证 L114-120) | 两个超时 return 均跳过 `streaming_output=False`/`output_queue=None`;daemon 线程继续写实例属性 `tool.output_queue`,该属性被后续调用覆盖到新会话队列 → 僵尸输出串流 |
| N7 | FTS5 关键词注入 | `subsystems/db.py:179` | 关键词内 `"` 未转义(应翻倍),可破坏短语边界改变 AND/OR 语义;语法错误时回退 LIKE,影响有限 |
| N8 | 流式输出旁路 TOOL_MAX_RESULT_LENGTH | `tools/db_tools.py:97-98` + `registry.py:104-110` | 超长记忆 content 经流式通道完整推到前端,100000 字符上限仅约束非流式返回值 |
| N9 | prompt 注入存储 | `tools/prompt_tools.py:64, 127, 227` | Agent 可控内容原样写入 `00_IDENTITY.md`/`99_SOUL.md`/`100_STATUS.md` 并在后续会话作为系统提示词加载,形成持久化注入;update_soul 可自我强化 |
| N10 | mark_as_triggered TOCTOU 竞态 | `subsystems/db.py:509-570` | 跨连接先读后写,并发触发 trigger_count 失真;建议 BEGIN IMMEDIATE 同事务读写 |
### 8.5 新发现——低危(7 项)
| # | 问题 | 位置 | 说明 |
|---|---|---|---|
| N11 | debug 日志打印工具敏感参数 | `tools/registry.py:66, 126` | debug=3 时 set_proxy 的 password 明文进 `~/.aibox/ai.log`(日志无 0600) |
| N12 | _load_persona 路径遍历(限 .md) | `aibox.py:527-543` | persona 参数 LLM 可控未净化,可读 SKILLS_DIR 外 .md 文件并注入子代理 |
| N13 | broadcast 裸 except + unregister KeyError 逃逸 | `aibox.py:2076-2082, 1971-1973` | 吞 CancelledError;并发注销时 `remove` 抛 KeyError;建议 `discard` + `except Exception` |
| N14 | _processing_tasks 按 ws id 覆盖,旧任务失联 | `aibox.py:2728-2742` | 连接关闭只能 cancel 最后一个任务;handle_command 不受 is_processing 保护 |
| N15 | llm_client 异常含完整响应体 | `llm_client.py:172-174` | 某些网关 4xx 页回显 Authorization 头 → 密钥进异常栈;建议截断 500 字符 |
| N16 | 历史索引裸 except + 归档文件名撞名覆盖 | `subsystems/conversation_history.py:60-66, 135-137` | Ctrl+C 被吞后空索引覆盖原文件;时间戳粒度不足同秒覆盖 |
| N17 | InstallSkillTool skill_path 无校验 | `tools/skill_tools.py:252-304` | 可引导复制任意敏感文件进技能目录,经 search_skills show_content 预览泄露 |
杂项(低危,随批处理):`executors.py:209/301/338` 裸 except 应收窄为 TimeoutExpired;`db.py` 多处 `with sqlite3.connect` 不显式 close(CPython 下无害,PyPy 下句柄延迟);`config.py:31-34` AI_CONFIG_PATH 可重定向配置加载(本地单用户风险有限);skill_manager 技能目录代码执行面维持 by design(建议文档化可信边界)。
### 8.6 更新后的架构合规矩阵
| 硬约束 | 第一轮 | 第二轮 |
|---|---|---|
| WebSocket :8765 | ✅ | ✅ |
| 依赖单向 `common←subsystems←tools←aibox` | ✅ | ✅ |
| Worker 仅 7 执行工具 | ✅ | ✅ |
| 工具输出 ≤1MB + 超时 + 中断 | ❌ H1/H2/H3 全破 | ⚠️ **executor 链已恢复**;残余:流式 db_tools 旁路长度上限(N8)、子代理超时无效(N5)、reader 泄漏(N4) |
| prompt 三级排序加载 / sqlite 线程安全 | ✅ | ⚠️ sqlite 主流程安全;mark_as_triggered 竞态(N10)、FTS5 注入(N7)待修 |
### 8.7 Batch F 修复建议(按优先级)
1. **F1(高)**:N1 代理密码脱敏——`set_proxy` 落盘前抹除 password 字段 + `save` 默认 False
2. **F2(中)**:N6 registry 超时路径补清理(两个 return 前置 `tool.streaming_output=False; tool.output_queue=None`)+ N4 reader 线程收尾(break 后 `stdout.close()` + `reader.join(2)`)
3. **F3(中)**:N5 子代理 cancel 传播——`run_full_subagent` 传递父 `_cancel_event`,每轮 think 前检查
4. **F4(中)**:N2 drain_thread 加 sentinel 退出 + N3 reset_conversation 移出事件循环(run_in_executor)
5. **F5(中)**:N7 FTS5 转义 + N8 流式截断 + N10 同事务读写 + 顺带修 L1(两处 LIMIT 参数化)+ L3(finally wait)
6. **F6(低)**:N9 prompt 内容过滤/确认门槛 + N11 日志脱敏 + N12 persona 路径校验 + N13/N14/N15/N16/N17 择机处理 + M3/M4 视部署平台决定
### 8.8 Batch F 修复进展
**F1 ✅ 已落地(2026-08-18)**——N1 + N11 一并修复:
- `common/config.py`:`set_proxy` 新增 `password_env` 参数;`_save_config` 落盘前 deepcopy 脱敏(明文密码强制置 None,纵深防御,任何保存路径均拦截);`get_proxy` 按"内存明文优先 → password_env 查环境变量"解析,下游(llm_client / web 工具 `_proxy_curl_args`)无感知;加载时检测旧版明文自动迁移(内存保留供本会话 + 立即重写脱敏文件,一次性清除存量)
- `tools/proxy_tools.py`:`SetProxyTool` 新增 `password_env` 参数,结果显示"密码来源"(环境变量 / 仅本次会话 / 无)
- `tools/registry.py`(N11):debug 日志对 `password/api_key/token/secret/authorization` 参数打码
- 测试:新增 `tests/test_config.py` 3 例(落盘无明文 / password_env 解析 / 旧配置迁移),全量 267 passed
- 行为变化:已有明文密码的配置重启后代理需重设密码或改用环境变量(迁移日志有提示)——清除存量明文的必然代价
**F2 ✅ 已落地(2026-08-18)**——N6 + N4 一并修复:
- N6(registry 超时路径补清理):两个超时 return(deadline 命中、join(5) 超时)统一改为 `timed_out` 标记 + 出口统一执行 `tool.streaming_output=False` / `tool.output_queue=None`;僵尸 daemon 线程的 `send_line` 落到 None 上不再外写,消除僵尸输出串流路径。`stream_tool_timeout` 提为实例属性(默认 3600s,测试可调)
- N4(reader 线程收尾,**含二次修正**):初版按审计建议"break 后 `stdout.close()` + `reader.join(2)`"实现,PoC 回归实证 **close 会阻塞**——CPython 流对象内部有锁,readline 持锁阻塞时 `close()` 一同挂起直到 readline 返回(PoC-1 从 3s 退化到 15.2s,恰为命令 Start-Sleep 时长)。修正后策略:reader 已正常退出(EOF)→ 显式 close 释放管道 fd;reader 仍阻塞 → 只做 `join(timeout=2)` 后放弃(daemon 兜底,孙进程退出收 EOF 后自然结束,fd 随管道消亡回收),主流程及时返回
- **审计建议修正记录**:8.3 节 N4 与 8.7 节 F2 中"close stdout 可中断阻塞 readline(CPython 行为)"的表述**不成立**,已按实测修正——跨线程中断阻塞中的管道 read 无便携方案,正确做法是控制泄漏窗口而非强行中断
- **测试**:新增 `tests/test_f2_fixes.py` 5 例(N6 超时/正常清理 ×2、N4 及时返回 ×2 + EOF 后 close ×1),PoC 回归 5/5(PoC-1/4 恢复 ~5s:超时 3s + join 宽限 2s),全量 272 passed
---
## 九、第三轮审计(2026-08-18,HEAD `aa15841`)
方法:前两轮遗留项逐条实证复核 + v3.4 新增代码(多模型档案 / L4a/b/c 失败转移,`llm_client.py`)全量审读 + 测试基线确认(全量 **355 passed**)。
### 9.1 遗留项修复状态复核
| # | 问题 | 状态 | 实证 |
|---|---|---|---|
| L3 | execute() kill 后未 wait() | ✅ **已修** | `executors.py:222-230`:kill/terminate → `wait(timeout=2)` → 超时再 kill 兜底 |
| N17 | InstallSkillTool 任意文件复制 | ✅ **已消解(设计变更)** | 工具重构为纯指引(只返回安装说明文本,不再执行 shell/复制);`CreateSkillTool` 增加 skill_name 正则校验(`^[A-Za-z0-9_-]+$`) |
| N1/N11 | 代理密码脱敏 / 日志打码 | ✅ 在位 | F1 修复无回归 |
| N4/N6 | reader 收尾 / registry 超时清理 | ✅ 在位 | F2 修复无回归 |
| N2 | drain 线程泄漏 | ❌ 未修 | `aibox.py:1280-1303`:循环条件仍只查 cancel 事件,注释"自动退出"与实现不符,每次工具调用泄漏一个常驻线程 |
| N3 | `/new` 阻塞事件循环 | ❌ 未修 | `aibox.py:2325`:2s 轮询竞态窗口后仍 `with self._msg_lock:`(同步 RLock acquire 于事件循环线程) |
| N5 | 子代理 cancel 不传播 | ❌ 未修 | `aibox.py:643-654`:子代理独立 `_cancel_event`/executor/client;`--once` Timer(3110-3113)只 cancel 父代理 |
| N7 | FTS5 引号未翻倍 | ❌ 未修 | `db.py:178`:`f'"{kw}"'` 直接拼,关键词含 `"` 破坏短语边界 |
| N8 | 流式旁路长度上限 | ❌ 未修 | `registry.py:120-129`:`("line", content)` 无限转发;`aibox.py:1326` 的 `MAX_RESULT_LENGTH` 只管回喂 LLM 的最终 result |
| N9 | prompt 注入存储 | ❌ 未修 | `prompt_tools.py`:confirm 默认 True,Agent 可控内容仍直写 00/99/100 文件 |
| N10 | mark_as_triggered TOCTOU | ❌ 未修 | `db.py:509-525`:跨连接先 `get_memo` 后 UPDATE,无 BEGIN IMMEDIATE |
| N12 | _load_persona 路径遍历 | ❌ 未修 | `aibox.py:529-545`:persona LLM 可控,`os.path.join(SKILLS_DIR, name + ".md")` 无边界校验 |
| N13 | broadcast 裸 except | ❌ 未修 | `aibox.py:2131`:`except:` 吞 CancelledError |
| N14 | _processing_tasks 覆盖 | ❌ 未修 | `aibox.py:2788`:按 ws id 覆盖登记;先完成任务的 done_callback 会误 pop 后续任务的登记 |
| N15 | 异常含完整响应体 | ⚠️ 部分改善 | 瞬时路径已截断 `[:200]`(163/213 行);**非瞬时路径仍全量**(`llm_client.py:215-216`) |
| N16 | 历史索引裸 except / 同秒覆盖 | ❌ 未修 | `conversation_history.py:64` 裸 `except:`;136 行归档文件名秒级时间戳 |
| M3/M4 | web scheme 校验 / shlex Windows | ❌ 未修 | 维持原判(Linux 部署不受影响) |
| L1 | LIMIT f-string | ❌ 未修 | `db.py:417, 459` 两处在位(内部 int 直达,防御性待修) |
### 9.2 v3.4 新代码审计(多模型档案 + L4 失败转移)
**设计评价:整体高质量**。免密端点与密钥隔离(`allow_key_file=False` 防跨厂商凭据泄漏)、用户中断绝不转移(cancel 广播全链 + `_cancelled` 门控)、流式仅首事件前重放(`produced_any` 门控)、fallback 链一层不递归 + 自引用/悬空引用硬性报错、构造失败跳过不阻断主档案——关键决策均正确且有测试锚定(`test_models_profile.py` 约 60 个 L4 用例)。
新发现(P 系列):
| # | 级别 | 问题 | 位置 | 说明 |
|---|---|---|---|---|
| P1 | 中 | 非瞬时 HTTP 错误响应体无截断(N15 残留) | `llm_client.py:215-216` | `RuntimeError(f"HTTP {code}: {response.text}")` 全量响应体进异常栈;部分网关 4xx 页回显 Authorization 头 → 密钥进日志。与瞬时路径 `[:200]` 不一致,建议同样截断 |
| P2 | 中 | Retry-After 无上限 | `llm_client.py:290` | `max(1.0, float(v))` 无上界——异常/恶意服务端 `Retry-After: 999999` 使档案冷却 11.5 天。建议 cap(如 `min(v, max(cooldown_seconds, 3600))`) |
| P3 | 低中 | fallback 档案缺 model 不校验 | `llm_client.py:671-673` | 主档案经 `_require_model` 硬性报错,fallback 档案直接 `fb_profile.get("model")` → model=None → API 400 → 表现为神秘 None 而非清晰配置错误。建议构造链时同样校验 |
| P4 | 低 | FailoverClient 非线程安全 | `llm_client.py:321-334` | `_cancelled`/`_call_started`/`_stats` 无锁。当前调用面无实际并发(compressor 在 `_msg_lock` 内同步调用、子代理独立 client),但 cancel(WebSocket 线程)与 generator 启动时 `_cancelled = False` 复位存在理论竞态窗口。建议注释声明"非并发契约"或加锁 |
| P5 | 低 | `LLMClient._cancelled` 死状态 | `llm_client.py:52, 76` | 只写不读:cancel() 置位后无消费方也从不清除,真正判断靠 FailoverClient 自身标志。建议删除或在底层 client 消费 |
| P6 | 低 | fallback 列表重复项不查重 | `llm_client.py:660-673` | `["kimi","kimi"]` 会在链内构造两个同名 client,冷却键同名冲突、重复撞同端点;影响有限 |
| P7 | 信息 | timeout_budget 仅在尝试间检查 | `llm_client.py:363-366, 404, 443` | 预算不截断单档案进行中的阻塞读(单次仍可超时到 timeout)。与文档"限制整条链尝试的总秒数"有细微出入,可接受,建议文档注明"尝试间预算" |
### 9.3 架构合规矩阵(第三轮)
| 硬约束 | 状态 |
|---|---|
| WebSocket :8765 | ✅ |
| 依赖单向 `common←subsystems←tools←aibox` | ✅ |
| Worker 仅 7 执行工具 | ✅ |
| 工具输出 ≤1MB + 超时 + 中断 | ⚠️ executor 链 ✅;残余:N8 流式旁路、N5 子代理超时、N2 drain 泄漏 |
| prompt 三级排序 / sqlite 线程安全 | ⚠️ N7/N10 待修 |
| 多模型档案凭据隔离(v3.4 新增维度) | ✅ 跨厂商 Key 不互发、免密端点显式声明 |
### 9.4 Batch G 修复建议(按优先级)
1. **G1(中)**:P1 非瞬时路径响应体截断 `[:200]` + P2 Retry-After 上限 cap——两处各一行改动
2. **G2(中)**:P3 fallback 档案补 `_require_model` 校验(缺 model 的 fallback 跳过并告警,与缺 Key 行为对齐)
3. **G3(中)**:N5 子代理 cancel 传播(`run_full_subagent` 传递父 `_cancel_event`,每轮 think 前检查)+ N2 drain 线程加 sentinel/空队列退出
4. **G4(中低)**:N3 `/new` 的 `reset_conversation` 移入 `run_in_executor` + N8 registry 转发行截断 + N12 persona 路径边界校验(`os.path.realpath` 前缀检查)
5. **G5(低)**:N7 FTS 引号翻倍 + N10 BEGIN IMMEDIATE + L1 LIMIT 参数化 + N13 `except Exception`/`discard` + N14 任务登记改按 task id 集合 + N16 收窄 except + 归档文件名加毫秒/序号
6. **G6(低/择机)**:P4 并发契约注释或加锁 + P5 死状态清理 + P6 fallback 去重 + N9 prompt 更新确认门槛 + M3/M4 视部署平台决定
### 9.5 测试基线
全量 **355 passed**(21.8s,Windows 开发机 `py -3`),含 L4 失败转移约 60 用例、Batch E PoC 回归 5/5、Batch F1/F2 修复测试 8 例。零回归。
---
## 十、Batch G 修复落地(2026-08-18,第三轮审计修复)
按 §9.4 建议分 5 波实施,每波全量测试验证后进入下一波。
### 10.1 修复清单
| 波次 | # | 修复内容 | 文件/位置 |
|---|---|---|---|
| W1 | P1 | 非瞬时 HTTP 错误响应体截断 `[:200]`(与瞬时路径对齐,密钥/错误页不进异常栈) | `llm_client.py:216` |
| W1 | P2 | Retry-After 解析上限 `min(max(1.0, v), 3600.0)`——恶意/异常头最多冷却 1h | `llm_client.py:284-296` |
| W1 | P3 | fallback 档案构造前补 `_validate_profile` + `_require_model`,配置缺陷跳过并告警(对齐缺 Key 行为) | `llm_client.py:681-695` |
| W1 | P5 | 删除 `LLMClient._cancelled` 死状态(只写不读;真实机制在 FailoverClient),cancel() 注释说明职责边界 | `llm_client.py:48-49, 71-76` |
| W1 | P6 | fallback 列表 `seen` 去重(自引用硬性报错保持不变,测试锚定) | `llm_client.py:669-679` |
| W1 | P4 | FailoverClient 类文档补并发契约声明(非线程安全 + 当前调用面为何安全) | `llm_client.py:324-327` |
| W2 | N8 | registry 流式转发受 `TOOL_MAX_RESULT_LENGTH` 约束:累计预算 + 超限截断 + 停止转发提示(一次性) | `tools/registry.py:113-145` |
| W2 | N12 | persona 路径边界校验:normcase+abspath 前缀检查,越界/绝对路径拒绝 | `aibox.py:536-544` |
| W3 | N2 | drain 线程重构:批次级单线程 + 独立 stop 事件 + try/finally 统一回收(正常/异常/中断路径均 join≤2s);`set_output_queue(None)` 移入 finally | `aibox.py:1254-1386` |
| W3 | N5 | 子代理取消传播:`run_full_subagent` 新增 `cancel_event` 参数→子代理共享父事件(think 前置/工具间/流式事件间既有检查点联动)+ 0.2s 观察者线程转发深度中断(强关在途 HTTP、kill 子进程),finally 通知退出;`SubAgentTool` 构造注入父 `_cancel_event` | `aibox.py:621-624, 669-684, 773-775, 786-790, 897-902, 1145-1147` |
| W4 | N3 | `/new` 的 `reset_conversation` + `prompt_manager.reload()` 移入 `run_in_executor`(不再同步获取 `_msg_lock` 冻结事件循环) | `aibox.py:2386-2400` |
| W4 | N13 | broadcast 裸 except → `except Exception`(不再吞 CancelledError);unregister `remove` → `discard`(幂等) | `aibox.py:2080-2084, 2185-2192` |
| W4 | N14 | `_processing_tasks` 改按 websocket 键的任务集合登记:done_callback 从集合 discard;连接关闭取消该连接全部在途任务(此前仅最后一个) | `aibox.py:2022, 2847-2870` |
| W5 | N7 | FTS5 关键词内双引号翻倍转义(`"` → `""`),MATCH 短语语法合法 | `subsystems/db.py:178-182` |
| W5 | L1 | 两处 `LIMIT {limit}` f-string → `LIMIT ?` 参数化(`get_pending_memos`/`get_immediate_memos`) | `subsystems/db.py:419-421, 462-465` |
| W5 | N10 | `mark_as_triggered` 读写收进单连接 `BEGIN IMMEDIATE` 事务(消灭 check-then-act 竞态:并发触发不再双计数/双推进 repeat) | `subsystems/db.py:515-590` |
| W5 | N16 | 历史索引加载裸 except → `except Exception` + 告警;归档文件名冲突时加 `_N` 序号(同秒归档不覆盖) | `subsystems/conversation_history.py:58-69, 138-146` |
未实施(按 §9.4 G6 择机项):N9(prompt 更新确认门槛,涉及交互设计)、M3/M4(web scheme 校验 / shlex Windows,Linux 部署不受影响)、P7(timeout_budget 语义为文档注记,已在 FailoverClient 文档说明)。
### 10.2 新增测试
`tests/test_g_fixes.py` **20 例**(全部通过):
- P1/P2 ×4:非瞬时错误体截断、Retry-After cap/正常值/非法值
- P3/P5/P6 ×2:fallback 缺 model 跳过 + 重复去重(链 = [main, dupe, ok2])、死状态删除
- N2 ×2:drain 线程批次结束/工具异常路径均回收
- N5 ×3:子代理共享父取消事件 / 未传时保留自身 / SubAgentTool 注册注入链路
- N7 ×2:含引号关键词不破坏检索、常规检索不回归
- N8 ×1:120000 字符流式输出被截断至预算 + 提示标记
- N10 ×2:缺失 id 事务路径返回 None、一次性任务完成语义
- N12 ×3:`../` 越界/绝对路径拒绝、合法 persona 正常加载
- N16 ×1:同秒两次归档文件名不冲突
N3/N13/N14 为 async WebSocket 路径,依赖事件循环与真实连接对象,由现有 WebSocket 测试面回归(全量通过)。
### 10.3 测试基线
全量 **375 passed**(22.5s,Windows 开发机 `py -3`)= 原 355 + Batch G 新增 20。零回归。
---
## 十一、第四轮审计(2026-08-18,HEAD `d1f780e`)
方法:3 路并行审读(aibox.py 全文 / llm_client+common+tools / subsystems+tests)+ 主审对全部候选发现逐条代码复核,关键项 Linux 动态实证(PoC 6/6)。
### 11.1 新发现与修复(Batch H,13 项全落地)
| # | 级别 | 问题 | 位置 | 修复 |
|---|---|---|---|---|
| H1 | **高危** | **N5 修复引入回归**:子代理共享父取消事件后,首轮 `_think_and_respond_impl` 无条件 `clear()` 吞掉父级已置位的取消信号(用户中断 / --once 超时恰落在子代理生成窗口时被清,观察者线程随之失效)——取消传播被绕过,子代理跑完全程 | `aibox.py:675, 1470` | 共享事件时置 `_shared_cancel_event` 跳过 clear;赋事件后立即检查 is_set 直接返回取消 |
| M1 | 中 | `/reload_prompts` 在事件循环线程同步获取 `_msg_lock` 冻结整个事件循环(N3 同类遗漏,只修了 `/new`) | `aibox.py:2492` | 移入 `run_in_executor`(与 /new 一致) |
| M2 | 中 | SkillManager 懒加载在事件循环线程串行执行 `--help` 子进程(每技能 ≤10s),首条消息/首个任务触发时冻结服务 | `aibox.py:2885-2891` | 启动前预热 WS 与调度器两个共享实例 |
| M3 | 中 | 调度器任务无超时:AI think 卡死 → `await ai_future` 永久阻塞 → 调度循环停摆、后续任务全部丢失(已 mark_as_triggered 不重试) | `aibox.py:333` | `call_later` 任务级超时(`scheduler.task_timeout`,默认 600s)→ `ai.cancel()`,循环继续 |
| M4 | 中 | `get_recent_memories(limit=None)` 无 LIMIT 全表加载(db_tools 分页传 None),记忆库增长后单次调用全量入内存 | `db.py:234` | None → 默认上限(对齐 search_memories 语义) |
| M5 | 中 | `memo delete` 实为 `complete_memo(force)`——只标记完成不删行,重复任务被推进而非移除,stats 计数失真 | `db_tools.py:747` | db 层新增 `delete_memo` 物理删除,工具改调 |
| M6 | 中 | `PromptManager.reload()` 先清空 `_prompts` 再逐文件重读:并发连接构建 system 消息时读到空/半截列表(N3 移线程池后放大) | `prompt_manager.py:298` | 本地构建新列表后原子替换 |
| L1 | 低 | read_file 的 `start_line/end_line` 未强转直拼 sed 单引号模板——LLM 传含引号字符串可闭合引号注入生成的指引命令 | `basic_tools.py:97` | int() 强转 + 范围夹取 |
| L2 | 低 | `_save_config` 吞异常仅 print:磁盘只读时 set_proxy 仍报"已保存",重启后配置丢失无感知 | `config.py:284` | `_save_config` 返回落盘结果,set_proxy 透传,工具如实报告 |
| L3 | 低 | SSE 行 `decode("utf-8")` 遇非 UTF-8 字节抛 UnicodeDecodeError,整条流中断且不触发 failover | `llm_client.py:222` | `errors="replace"` |
| L4 | 低 | persona 路径校验用 abspath 未解析符号链接:skills 目录内软链指向任意文件可绕过前缀检查被读取注入 | `aibox.py:536` | realpath 后再做前缀校验 |
| L5 | 低 | 1MB 截断按字节切后 `errors='replace'` 解码,行尾出现半个字符 � | `executors.py:150` | 改 `errors='ignore'` |
| L6 | 低 | compressor 压缩块全空内容时 `original_tokens=0` → 除零崩溃 | `conversation_compressor.py:517` | `max(original_tokens, 1)` |
| L7 | 低 | InstallSkillTool 指引命令 `ls -l {src}`/`cp {src} {dst}` 未引用:LLM 可控路径含空格/元字符时 shell 拆词/注入 | `skill_tools.py:269-285` | shlex.quote 源与目标路径 |
| L8 | 低 | ask_user 的 required 为空,LLM 不传 question 时 `str + None` 抛 TypeError | `misc_tools.py:42` | required 补 question + 缺参明确报错 |
| L9 | 低 | Linux 孙进程持管道泄漏(N4 残余变体):`(sleep 300 &)` 形态 shell 已死但孙进程持写端,reader 阻塞无 EOF;**实证修复前孙进程残留** | `executors.py` | POSIX `start_new_session` + 进程组信号(组 ID 在 Popen 后立即捕获,shell 死亡后 getpgid 不可用);宽限窗口到期强杀进程组 |
| L10 | 低 | web 工具 url 无 scheme 校验(M3 遗留):`file://` 等经 curl 指引可达(SSRF) | `basic_tools.py:319` | http/https 白名单 |
| L11 | 低 | registry 日志打码精确匹配:apiKey/passwd 等变体漏网 | `registry.py:19` | 子串包含匹配(含 apiKey/passwd/credential) |
| L12 | 低 | FailoverClient 异常消息经 `_log` 落 ai.log:网关错误页若在 200 字符内回显 Authorization 头则密钥进日志 | `llm_client.py` | `_log`/on_failover 前 Bearer 令牌正则脱敏 |
### 11.2 修复实证(Linux PoC 6/6 PASS)
```
[PASS] L-1 无输出命令超时: 3.0s 返回,含超时(原 PoC-1 为 Windows powershell 命令)
[PASS] L-3 1MB截断: 结果 1.00MB(Linux 等价命令)
[PASS] L-4 cancel中断: cancel 后 2.0s 结束
[PASS] L-5 工具层1MB贯通: 结果 1.00MB
[PASS] L-9 孙进程持管道不悬挂: 1.1s 返回,reader 正常收尾
[PASS] L-9 孙进程已被清理: 残留无(修复前 `(sleep 300 &)` 孙进程残留至自然退出)
```
L-9 实证过程说明:首版实现按"break 后 killpg"处理,但 shell 已死时 `os.getpgid(pid)` 抛 ProcessLookupError,组 ID 无法反查,孙进程仍残留(实证 FAIL 抓出)。修正为 **Popen 成功后立即捕获组 ID 并保存**,killpg 用保存值——6/6 通过。
### 11.3 遗留项(维持原判 / 择机)
- **N9**(prompt 注入存储):confirm 默认 True、100_STATUS 直写无门槛——涉及交互设计(用户确认门槛),维持 G6 择机项
- **M4/M3(旧编号)**:shlex Windows 转义(Linux 部署不受影响)、Q9 代理认证 curl 指引(修复需把密码嵌入命令串,反而引入密码进日志/会话史的新泄漏,维持现状)
- **F5** express_emotion 不可中断、**F6** /new 占用默认线程池、**F8** WS 限速/空闲回收、**F9** --once 软超时(H1 修复后取消可传播,残余窗口受 timeout 有界)、**Q7a** stream_chat 取消空窗(受 timeout 有界)、Q4 busy_timeout 重试、Q5 提醒格式异常静默、Q7b load_conversation 无调用面、Q8 对话文件原子写、compressor 裸 print / token 双计、单例 __new__ 无锁、db.py `if limit:` limit=0 边界——均为低危/信息级,下批处理
- **测试环境注记**:`test_executors.py::test_execute_timeout` 与 `test_models_profile.py::test_flat_api_still_reads_key_file` 为 Windows 向用例(ping -n 语法 / 依赖 ~/.cert/deepseek.key),Linux 环境预存失败,与本轮改动无关(git stash 实证)
### 11.4 测试基线(Linux,`aibox/venv`)
全量 **408 passed** = 原 382(Linux 环境)+ Batch H 新增 26。零回归;2 个预存环境性失败同前。PoC 回归脚本为 Windows 命令集,Linux 用等价命令实证(见 11.2)。
---
## 十二、Batch I 修复落地(2026-08-18,遗留项收尾)
按 §11.3 遗留清单分 3 波实施:安全项 → 稳定性项 → 信息级顺手项。
### 12.1 修复清单
| # | 级别 | 修复内容 | 文件/位置 |
|---|---|---|---|
| N9 | 中 | prompt 写入内容长度上限(持久化注入面约束):`PromptManager.MAX_CONTENT_LENGTH=10000`,update_identity/update_soul/update_status 超长拒绝(manager 兜底 + 工具友好提示);save_memory_and_end_conversation 状态内容超长拒绝 | `prompt_manager.py`、`prompt_tools.py` |
| Q7b | 低 | `load_conversation` realpath 前缀校验,`../` 越界/软链逃逸返回 None(当前无 LLM 调用面,防御性加固) | `conversation_history.py:348` |
| F5 | 低 | 情绪子代理取消传播:`run_light_subagent` 新增 `cancel_event`,改走 stream_chat(可被 cancel 强关在途 HTTP)+ 观察者线程;父已取消早退;`ExpressEmotionTool` 构造注入父 `_cancel_event` | `aibox.py:513, 608-660, 1177` |
| Q4 | 低 | `mark_as_triggered` BEGIN IMMEDIATE 前 `PRAGMA busy_timeout = 10000`——多进程同库(CLI+服务)写竞争等待锁释放而非 5s 后直接报 locked | `db.py:527` |
| Q5 | 低 | 提醒时间格式异常任务不再静默丢弃:去掉 SQL 层 `reminder_time <= ?` 预过滤(字符串比较会静默排除格式异常行),时间过滤改由 Python 二次验证权威判断;格式异常按"到期"返回并告警 | `db.py:405-411, 442-444` |
| Q8 | 低 | 对话文件原子写:`_atomic_write_json`(tmp + os.replace)覆盖历史索引/当前对话/归档三处写路径,崩溃不留半截 JSON | `conversation_history.py` |
| F6 | 低 | WS 命令专用线程池:`WebSocketHandler._cmd_executor`(max_workers=2),`/new`、`/reload_prompts` 不再占用默认池(繁忙 AI 时多连接并发 /new 不再耗尽线程池);服务退出时 shutdown | `aibox.py` |
| F9 | 低 | --once 硬超时兜底:软超时 cancel 后若 AI 卡死在不可中断路径(HTTP 空窗、忽略信号的子进程),宽限一个超时周期后 `os._exit(4)` 强杀,保证硬性结束语义 | `aibox.py:3283-3299` |
| — | 信息 | PromptManager 单例 `__new__` 加锁(并发首例化竞态防护) | `prompt_manager.py:49-53` |
| — | 信息 | `get_pending_memos`/`get_immediate_memos` 的 `if limit:` → `if limit is not None`(limit=0 语义修正,None 才不加 LIMIT) | `db.py:420, 464` |
### 12.2 维持原判(未改动)
- **Q7a**(stream_chat 取消空窗):POST 返回前 `_current_response` 未赋值,cancel 无法中断——受 timeout 有界;根治需线程化 POST + 轮询取消,复杂度高,维持记录
- **M4(旧编号)**(shlex Windows 转义):Linux 部署不受影响
- **Q9(代理认证 curl 指引)**:修复会把密码嵌入命令串(进日志/会话史),反而引入新泄漏,维持现状
- **F8**(WS 限速/空闲回收):本地单用户产品设计项
- compressor 裸 print(终端过程展示,保留)、`estimate_tokens` 英文双计(测试锚定既有算法,仅影响压缩阈值)
### 12.3 新增测试(`tests/test_i_fixes.py` 16 例,全部通过)
N9 ×5(manager 兜底/身份/灵魂/状态超长拒绝 + 正常写入)、Q7b ×2(越界拒绝 + 正常加载)、F5 ×3(父已取消早退/流式中断/注册注入链路)、Q5 ×2(格式异常按到期返回 + 正常过滤不回归)、Q8 ×1(无 .tmp 残留 + JSON 合法)、F6 ×1(专用线程池存在与释放)、limit=0 ×2。
### 12.4 测试基线(Linux,`aibox/venv`)
全量 **424 passed** = 原 408 + Batch I 新增 16。零回归;2 个预存环境性失败同前。
+358
View File
@@ -0,0 +1,358 @@
# aibox CLI 单次调用模式 · 开发文档
> 文档类型:功能开发计划
> 模块:aibox(单 AI 智能体系统)
> 日期:2026-08-17
> 状态:方案研究(待评审)
> 影响范围:`aibox/aibox.py`(仅增量改动,WebSocket 模式零回归)
---
## 1. 概述
### 1.1 背景与动机
aibox 目前只有两种使用方式:
| 使用方式 | 入口 | 特点 |
|----------|------|------|
| WebSocket 服务器 | `python aibox.py` → `ws://localhost:8765` | 长连接、多轮对话、前端/BotOps 交互(核心场景) |
| 备忘录 CLI | `--check-memo` / `--memo-add` / `--memo-list` / `--memo-complete` | 只做任务 CRUD,不触发 AI 对话 |
缺少一种**"问一次就退"**的 CLI 一次性对话模式。这导致以下场景无法覆盖:
1. **脚本化调用**:无法在 shell 脚本 / cron / CI 流水线中把 AI 当命令行工具用(如 `aibox --once "统计当前目录 py 文件行数" | grep ...`)
2. **资源受限环境**:树莓派 / NPU 盒子上不想常驻一个 WebSocket 服务进程,只想按需拉起、用完即退
3. **调试与测试**:复现某个 prompt 行为必须启动服务器 + 前端连接,链路太重
4. **进程级隔离执行**:Worker 子代理当前是**同进程线程**(`run_full_subagent` → `DeepSeekChat`),子代理崩溃/死循环会影响主服务;有了 CLI 单次模式后,可演进为"独立子进程执行",OS 级隔离
### 1.2 设计目标
1. **新增 `--once QUERY` 参数**:执行一次性 AI 对话(含完整工具调用链),结果流式输出到 stdout,完成后进程退出
2. **纯增量、零回归**:不改动 WebSocket 模式任何现有行为;不加参数时行为与现状完全一致
3. **复用现有架构**:复用 `EnhancedAIManager` / `DeepSeekChat` / Orchestrator-Worker 工具分层 / 三层中断机制,不重写对话逻辑
4. **管道友好**:stdout 只出 AI 回答正文,过程行(🔧 工具调用等)走 stderr,退出码语义化,可被 shell 脚本安全组合
5. **状态隔离明确**:单次模式默认不加载历史对话、不写对话归档,记忆写入显式可控
### 1.3 非目标
- 不做多轮对话 CLI(交互式 REPL 已由 `client.py` 覆盖)
- 不做 HTTP/REST 单次接口(如需可在后续批次评估,复用同一内核)
- 不改变 Worker 子代理的线程内执行方式(进程化是后续可选演进,不在本期范围)
---
## 2. 现状分析
### 2.1 入口结构(aibox.py)
```
parse_args() # L2786: argparse 定义
main() # L2927: 主入口
├── 语言/配置/调试覆盖 # L2931-2959
├── 目录创建 + 日志初始化 # L2961-2973
├── SubprocessCommandExecutor # L2976
├── EnhancedAIManager # L2978-2981
├── handle_check_commands() # L2983: --check-memo 处理后 return
├── handle_memo_commands() # L2986: --memo-* 处理后 return
└── manager.run() # L2990: WebSocket 服务器(唯一终点)
```
关键观察:
- `handle_check_commands` / `handle_memo_commands` 已经是**"处理后即退出"的 CLI 模式先例**,单次对话模式可完全沿用该模式:在 `manager.run()` 之前插入 `handle_once_mode(manager)`,命中则执行并 return
- `EnhancedAIManager.add_ai()`(L2584)已封装完整的 AI 实例组装(memory_db / memo_db / history_manager / prompt_manager / command_executor 注入),单次模式可直接复用
- `DeepSeekChat.think_and_respond(input_text, output_callback)`(L1308)是同步阻塞函数,天然适合 CLI 直接调用——不需要 asyncio 事件循环
### 2.2 output_callback 消息类型(流式输出契约)
`think_and_respond` 通过 `output_callback(msg_type, content)` 实时推送:
| msg_type | 语义 | CLI 模式去向 |
|----------|------|--------------|
| `chunk` | AI 回答增量片段(**权威内容来源**) | **stdout**(流式 write + flush) |
| `complete` | 收尾信号(内容与 chunk 累积重复) | 仅作收尾标记,不重复输出 |
| `reasoning_start/reasoning/reasoning_end` | 思考模式流式 | stderr(可用 `--show-reasoning` 控制) |
| `line` | 过程行:🔧 工具调用、工具输出、💬 前缀 | stderr |
| `ai_break` | 工具调用分段信号 | 忽略(CLI 无 widget 概念) |
| `error` | API/解析错误 | stderr + 退出码 2 |
| `dialogue_ended` | AI 主动结束对话 | 正常收尾 |
### 2.3 需要处理的技术要点
| 要点 | 现状 | 风险与对策 |
|------|------|-----------|
| **压缩输出污染** | `_think_and_respond_impl` L1346 中 `print("📦 对话压缩...")` 直接打 stdout | 单次模式默认不加载历史 → 消息极少 → 几乎不触发压缩;另需确认该 print 走 stderr 或抑制(Batch C 处理) |
| **记忆写入并发** | 多个 CLI 进程并发写 `memories.db`(SQLite) | 默认 `--once` 模式 AI 侧仍可调用 `memory` 工具(orchestrator profile);SQLite 有文件锁兜底,低频并发无碍;提供 `--no-save-memory` 开关时通过提示词引导(见 §3.4) |
| **Ctrl+C 中断** | 三层取消机制(Event + HTTP close + 子进程 kill)依赖 `cancel()` 被调用 | CLI 模式注册 `signal.SIGINT` handler 调 `manager.cancel()`,与 WebSocket `handle_interrupt` 路径等价 |
| **子代理输出转发** | `SubAgentTool._subagent_output` 经 `send_line` → `output_queue` → 回调链 | CLI 模式的 `output_callback` 会自然收到子代理的 `line`/`reasoning`,全部导流 stderr,无需特殊处理 |
| **对话归档写入** | `global_exit_handler` 会调 `history_manager.end_current_conversation()` | 单次模式传 `exit_callback=None`(与 TaskScheduler 的任务实例一致),AI 主动 `save_memory_and_end_conversation` 时不触发全局归档 |
---
## 3. 设计方案
### 3.1 命令行接口
```bash
# 基本用法:一次性对话,结果输出到 stdout
python aibox.py --once "帮我统计当前目录下所有 py 文件的行数"
# 静默模式:只输出最终回答正文(工具过程完全静默,适合管道)
python aibox.py --once "把 README.md 翻译成英文摘要" --quiet
# 显式控制
python aibox.py --once "..." --no-save-memory # 不写入记忆数据库
python aibox.py --once "..." --timeout 120 # 全局超时 120 秒(默认 300)
python aibox.py --once "..." --show-reasoning # stderr 显示思考过程(默认不显示)
```
新增参数(全部可选,`--once` 之外均有默认值):
| 参数 | 类型 | 默认 | 说明 |
|------|------|------|------|
| `--once QUERY` | str | — | 一次性对话模式:执行 QUERY 后退出,不启动 WebSocket |
| `--quiet` | flag | false | 仅 stdout 输出回答正文;过程行/thinking 全静默 |
| `--no-save-memory` | flag | false | 禁止本次对话写入记忆数据库 |
| `--timeout SEC` | int | 300 | 全局执行超时,超时 kill 并退出码 4 |
| `--show-reasoning` | flag | false | 在 stderr 显示思考过程流 |
### 3.2 退出码语义
| 退出码 | 含义 |
|--------|------|
| 0 | 成功(AI 正常完成回答) |
| 1 | 工具执行链路异常(如 run_command 失败且 AI 报告失败) |
| 2 | API 请求失败(网络/HTTP/JSON 解析,服务端已回滚消息) |
| 3 | 用户中断(Ctrl+C) |
| 4 | 全局超时(--timeout 到期) |
> Batch A 只保证 0 / 2 / 3 / 4;退出码 1 的判定(从 AI 文本中识别失败)在 Batch B 实现,初版可统一归 0。
### 3.3 核心实现(Batch A,约 60~80 行增量)
**改动点 1:`parse_args()`(aibox.py L2786)** 追加 §3.1 的 5 个参数定义。
**改动点 2:新增 `handle_once_mode()`**,插入 `main()` 中 `handle_memo_commands()` 之后、`manager.run()` 之前:
```python
def handle_once_mode(manager, args) -> bool:
"""一次性对话模式:命中 --once 则执行并返回 True(进程退出)。"""
if not args.once:
return False
ai = manager.add_ai()
ai.history_manager = None # 单次模式不加载/不写对话历史
if args.no_save_memory:
ai.memory_db = None # 工具注册时已注入;此处断引用防止运行期写入
# 注:Batch A 中 memory 工具已在 _register_tools 注册;
# 完整禁用见 §3.4 说明
exit_code = [0]
def cli_callback(msg_type, content):
if msg_type == "chunk":
sys.stdout.write(content)
sys.stdout.flush()
elif msg_type == "complete":
sys.stdout.write("\n")
sys.stdout.flush()
elif msg_type == "error":
print(content, file=sys.stderr)
exit_code[0] = 2
elif msg_type == "line" and not args.quiet:
sys.stderr.write(content)
sys.stderr.flush()
elif msg_type.startswith("reasoning") and args.show_reasoning:
sys.stderr.write(content)
sys.stderr.flush()
# ai_break / dialogue_ended 忽略
# Ctrl+C → 三层取消(与 WebSocket interrupt 路径等价)
def _sigint(sig, frame):
manager.cancel()
signal.signal(signal.SIGINT, _sigint)
# 全局超时:守护线程到点 cancel
timer = threading.Timer(args.timeout, manager.cancel)
timer.daemon = True
timer.start()
try:
response = ai.think_and_respond(args.once, cli_callback)
if response is None or (isinstance(response, str)
and response.startswith(f"\n{i18n.get('error_prefix')}")):
exit_code[0] = max(exit_code[0], 2)
# 被 cancel 打断时 think_and_respond 返回 interrupt 文案
if ai._is_cancelled() and timer.is_alive() is False:
exit_code[0] = 4
finally:
timer.cancel()
ai.cancel() # 兜底清理子进程
sys.exit(exit_code[0])
```
**改动点 3:`main()` 一行插入**:
```python
if handle_memo_commands(manager):
return
if handle_once_mode(manager, args): # 新增
return
manager.run()
```
> 注:`handle_once_mode` 内部 `sys.exit()`,return 分支实际不可达,保留 return 仅为与 `handle_check_commands` / `handle_memo_commands` 模式一致。
### 3.4 记忆写入的处理
`--no-save-memory` 的**完整实现**需要在工具注册层禁用 `MemoryTool`,但 `_register_tools` 依赖构造期注入的 `memory_db`。Batch A 采用轻量方案:
- **Batch A**:仅断 `ai.memory_db = None` 引用 + 文档声明"`--once` 场景不建议依赖记忆";AI 若调用 `memory` 工具会因注入的 db 为 None 由工具自身报错兜底(`MemoryTool.execute` 内部访问 db 返回错误字符串,不会崩溃)
- **Batch B**:`DeepSeekChat.__init__` 增加 `enable_memory: bool = True` 形参,为 False 时 `_register_tools` 跳过 `MemoryTool` 注册,工具 schema 不出现在请求中,LLM 无从调用——这才是干净方案
### 3.5 与既有架构约束的对齐
| 项目硬约束 | 本方案如何满足 |
|-----------|---------------|
| WebSocket localhost:8765 通信 | 不受影响;`--once` 不启动 WebSocket,互斥使用 |
| Worker 仅 7 种执行工具 | 不受影响;单次模式沿用 orchestrator profile,工具分层不变 |
| 工具输出 ≤1MB + 超时中断 | 不受影响;复用 `SubprocessCommandExecutor` |
| 单向依赖 common←subsystems←tools←aibox | 满足;改动全部在 aibox.py 聚合层 |
| 新增/变更模块三处同步(根README/docs/MODULES/ARCHITECTURE) | 本功能非新模块,但落地时需同步更新 `aibox/README.md` 的"命令行参数"章节 |
---
## 4. 分批次实施计划
遵循项目"增量重构、低风险先行"惯例:
### Batch A:最小可用版(核心,建议先做)
- [ ] `parse_args()` 新增 `--once` / `--quiet` / `--timeout` 三个参数
- [ ] 新增 `handle_once_mode()`(§3.3 实现)
- [ ] `main()` 插入调用点
- [ ] SIGINT handler + 全局超时 Timer
- [ ] 退出码 0/2/3/4
- [ ] **验收**:`python aibox.py --once "现在几点了"` 正常输出并退出;`echo $?` 为 0;WebSocket 模式回归(启动服务器 + client.py 对话一轮)
### Batch B:体验与边界补齐
- [ ] `--no-save-memory` / `--show-reasoning` 参数落地
- [ ] `DeepSeekChat.__init__` 增加 `enable_memory` 开关,干净禁用 MemoryTool
- [ ] `_think_and_respond_impl` L1346 压缩提示 `print` 改走 stderr(或注入 print 回调)
- [ ] 退出码 1(工具失败识别)
- [ ] 补充 `tests/test_once_mode.py`:mock LLM 客户端,验证 stdout/stderr 分流、退出码、超时路径
- [ ] 更新 `aibox/README.md` 命令行参数章节
### Batch C:安全加固(可选,按需)
- [ ] SQLite WAL 模式(`PRAGMA journal_mode=WAL`),缓解多 CLI 进程并发写锁等待
- [ ] `--worker-only` 开关:`tool_profile=worker` 纯执行面模式,供其他模块以子进程方式调用 AI 执行器(为"Worker 进程级隔离"演进铺路)
- [ ] `--model` / `--temperature` 覆盖参数
### 明确不做(本期)
- 多轮交互式 CLI REPL(`client.py` 已覆盖)
- HTTP 单次接口
- Worker 子进程化(架构演进议题,单独立项)
---
## 5. 风险清单
| 风险 | 等级 | 缓解 |
|------|------|------|
| 冷启动开销(PromptManager 加载 + SQLite 初始化 + LLM 握手 ≈1~3s) | 低 | 可接受取舍;批量场景文档引导用 WebSocket 长连接 |
| CLI 模式与 WebSocket 模式代码 drift | 中 | 复用 `EnhancedAIManager.add_ai()`,单次模式新增代码控制在 80 行内,核心对话逻辑零复制 |
| `print` 污染 stdout 破坏管道 | 中 | Batch A 用 `--quiet` 兜底;Batch B 根治压缩提示 print |
| 并发进程 SQLite 写锁 | 低 | 低频无碍;Batch C WAL 加固 |
| Ctrl+C 时子进程泄漏 | 低 | 复用现成三层取消;`finally` 中 `ai.cancel()` 兜底 |
---
## 6. 测试方案
### 6.1 单元测试(tests/test_once_mode.py,Batch B)
| 用例 | 验证点 |
|------|--------|
| 正常问答 | mock LLM 返回纯文本 → stdout 含回答、退出码 0 |
| 工具调用链 | mock LLM 返回 tool_calls → line 走 stderr、chunk 走 stdout |
| API 失败 | mock LLM 抛 RequestException → 退出码 2、stderr 含错误 |
| 超时 | mock LLM 阻塞 → Timer 触发 cancel → 退出码 4 |
| SIGINT | 发送 SIGINT → 三层取消触发 → 退出码 3 |
| `--quiet` | 过程行不输出到任何流 |
| `--no-save-memory` | memories.db 无新增行 |
### 6.2 回归测试
- [ ] `py -m pytest aibox/tests -q` 现有 232 项全过
- [ ] WebSocket 模式手测:启动服务器 + `client.py` 完成一轮对话 + Ctrl+C 中断
### 6.3 手动验收脚本
```bash
# 基础问答(无工具)
python aibox.py --once "用一句话介绍你自己" ; echo "exit=$?"
# 工具调用链(会派生 worker 子代理执行命令)
python aibox.py --once "在 ~/.aibox/workspace 下创建一个 hello.txt 写入 hello" ; echo "exit=$?"
# 管道组合
python aibox.py --once --quiet "列出当前目录 py 文件" | grep ".py" | wc -l
# 超时路径
python aibox.py --once --timeout 5 "运行 sleep 60" ; echo "exit=$?" # 期望 4
```
## 7. 实施状态(2026-08-17 落地记录)
> 本节为实施后回填,与第 3/4 节计划存在少量偏差,以本节为准。
### 7.1 提交与批次
| 批次 | 提交 | 内容 |
|---|---|---|
| Batch A | `ce3fb7f` | `--once` / `--quiet` / `--once-timeout`(300s 默认)+ `handle_once_mode` + SIGINT/超时复用三层取消 |
| Batch B | `9e0d39c` | `--no-save-memory`(`enable_memory` 构造开关干净禁用 MemoryTool)、`--show-reasoning`(思考流默认静默走 stderr)、压缩提示改走 output_callback、退出码 1(orchestrator 层工具异常)+ `test_once_mode.py` 10 项 |
| Batch C | `a5af4bb` | SQLite WAL、`--worker-only`(worker profile + 精简 system prompt 防人格污染)、`--model` / `--temperature`、子代理失败标记透传(`_SUBAGENT_TOOL_FAILED_MARKER` → orchestrator `stats.tool_failed`)+ 5 项测试 |
| Batch D | `d03dbc7` | 多模型档案 `models` 配置节(本档案为实施中新增需求,超出原计划范围)+ `test_models_profile.py` 17 项 + README 更新 |
### 7.2 与计划的偏差
1. **超时参数名**:计划 `--timeout`,落地为 `--once-timeout`(避免与 `api.timeout` 语义混淆)
2. **`models` 默认留空 `{}`**:计划未涉及;实施中发现默认档案硬编码 `DEEPSEEK_API_KEY` / 官方 URL 会覆盖用户自定义配置(本机为 `ARK_API_KEY` + 火山方舟端点,曾导致 401),故默认空 dict,未配置档案时零破坏回退旧 `api` 扁平节
3. **`--worker-only` 需要精简 system prompt**:原计划仅切 tool_profile;实测 orchestrator 的人格/身份提示词会污染 worker 语义(自称"老板"、主动写文件、多轮不收敛),已对齐 `run_full_subagent` 的子代理语义修复
4. **退出码 1 的边界**:仅覆盖 orchestrator 直接感知的工具异常;worker 子代理内的命令失败经 Batch C 的标记透传后同样计入。子命令失败但 AI 正常汇报时退出码为 0(AI 视角任务完成)
### 7.3 验证结果
- 单测 **264 项全过**(once 模式 15 + 多模型档案 17 新增)
- 冒烟:基础问答 exit 0 / 工具调用链(子代理执行 echo)exit 0 / 超时路径 exit 4 / `--no-save-memory --quiet` stdout 干净 / `--show-reasoning` 思考流走 stderr / `--temperature` 透传 / 不存在的模型名 exit 2
### 7.4 明确不做(维持原判)
WebSocket 会话恢复、`--once` 结果缓存、批处理多 query——见第 4 节"明确不做"。
---
## 8. 后续演进方向(不在本期)
1. **Worker 进程级隔离**:`run_full_subagent` 的线程内 `DeepSeekChat` → `subprocess.run(["python", "aibox.py", "--once", "--worker-only", ...])`,子代理崩溃不波及主服务,可用 OS 用户/chroot 做权限隔离
2. **HTTP 单次接口**:`POST /chat` 无状态接口,与 `--once` 共享内核
3. **结构化输出**:`--json` 输出 `{answer, tool_calls, exit_reason}` JSON,供程序消费
---
## 9. 决策记录
| 决策点 | 结论 | 理由 |
|--------|------|------|
| 新入口文件还是复用 aibox.py | 复用 aibox.py + `--once` 参数 | 沿用 `--check-memo` 先例;避免维护两份入口 drift |
| stdout 输出协议 | chunk 流式 + 末尾换行 | 与 WebSocket 的 chunk 权威语义一致;管道可逐段消费 |
| 过程行去向 | stderr | Unix 管道惯例:stdout 是数据、stderr 是元信息 |
| 单次模式是否加载历史 | 否 | "一次性"语义明确;避免历史污染导致行为不可复现 |
| 是否默认写记忆 | 默认与 orchestrator 一致(可写),`--no-save-memory` 显式关闭 | 与 WebSocket 模式行为对齐,减少心智负担 |
@@ -0,0 +1,156 @@
# pybot×actpred 数据集录制与训练闭环评估实验报告
> 文档类型:实验报告
> 模块:pybot → actpred
> 作者:自动生成
> 日期:2026-08-14
---
## 1. 概述
### 1.1 背景
在微动作分解与自监督录制特性(见 `pybot_micro_action_and_recording_20260813.md`)基础上,本实验走通完整链路:dry-run 批量录制全部 `.mt` 动作脚本 → actpred 训练(未归一化 vs z-score 归一化两个版本)→ R4 离线闭环评估,并基于评估结果给出下一步开发方案(见 `actpred_action_norm_weighting_plan_20260814.md`)。
### 1.2 实验目标
1. 批量录制全部 12 个动作脚本,产出 actpred 格式数据集
2. 训练两个对照模型:`normalize_actions=False`(旧默认)与 `=True`(新默认,commit `a2c3a57`)
3. 用 `evaluate.py`(R4 闭环代理评估)对比两模型,定位误差主导列
4. 暴露并修复训练/评估链路的缺陷
### 1.3 关键结论速览
- **闭环舵机角无发散**,误差主导列是 `spk.F`(扬声器频率,10→1000Hz 大跨度)
- 归一化使聚合 MSE 降低 2.6×(6447→2445),但存在**舵机精度 ↔ 频率跟踪的权衡**
- `oob_rate` / `error_growth` 两个指标在含恒值列的退化数据集上失效,需修正(见开发方案 §3)
- 修复 1 个潜伏 bug:`normalize_actions=True` 时 val_ds 找不到 action_stats(commit `7ec8c9d`)
---
## 2. 数据集批量录制
### 2.1 方法
`scripts/*.mt` 均无 `@Camera` 块(该块为 pybot 新增,原脚本与 bot4cj 共享故未改)。采用**注入式录制**:不改动原脚本,为每个 `.mt` 在 `@Action` 前注入 `@Camera`(640×480@30fps)生成变体到 `data/pybot_record_scripts/`,再逐个 dry-run 录制。批处理脚本:`data/record_all.sh`(可重复执行)。
新增录制脚本 `bot4cj/scripts/record_stand.mt`(commit `af0ff56`):基于 std.mt 扩展 @Camera + MPU6050 绑定 + 5 行 @Action(delay=200),产出 81 步插值序列。
### 2.2 录制结果
dry-run 下帧为合成随机噪声、IMU 全零(Mock I2C 读零)。共 13 个 episode、865 帧、804 个训练样本(history_len=5, frame_len=3):
| Episode | 脚本 | 步数 | Episode | 脚本 | 步数 |
|---|---|---|---|---|---|
| 0000 | record_stand | 81 | 0007 | stdd | 46 |
| 0001 | jump | 27 | 0008 | t0 | 211 |
| 0002 | left-hand | 123 | 0009 | tilt-b | 46 |
| 0003 | left-turn | 58 | 0010 | tilt-f | 46 |
| 0004 | lie-down | 11 | 0011 | tilt-l | 46 |
| 0005 | rght-hand | 123 | 0012 | tilt-r | 46 |
| 0006 | std | 1 | | | |
注:episode_0006 仅 1 步(单行 @Action 首行不分解,属设计行为),对训练无贡献。仅 episode_0000 有 imu.npy(全零),其余无 IMU 文件,`ActionDataset` 按零向量处理。
### 2.3 数据验证
- `ActionDataset`(imu_dim=0 与 =8 两种模式)加载通过,804 样本 0 形状异常
- 微动作插值验证:s1 舵机 45°→60° 按 20 微步线性插值(每步 0.75°),`spk.delay` 微步期 10ms,与设计一致
- 每步帧独立(`bfa6d8d` 同步 capture 修复生效)
---
## 3. 训练实验
### 3.1 实验设置
| 项 | 值 |
|---|---|
| 分辨率 | 640×480(不降分辨率) |
| 增强 | ColorJitter(--augment) |
| batch / workers | 4 / 2(CPU 训练,~4 分钟/epoch) |
| epochs / patience | 100 / 15(early stopping) |
| 训练/验证 | 12 / 1 episode(763 / 41 样本,seed 42) |
### 3.2 模型 A:未归一化(normalize_actions=False,旧默认)
- 50 epochs 触发 early stopping,**best val_loss = 19.02**(原始量纲 MSE,数百量级)
- loss 轨迹:4344(epoch 1)→ 296(epoch 50),val 收敛至 ~20-30
### 3.3 模型 B:z-score 归一化(normalize_actions=True,新默认)
- 26 epochs 触发 early stopping,**best val_loss = 0.0288**(归一化量纲 <1.0,符合 commit `a2c3a57` 预期)
- loss 轨迹:0.188(epoch 1)→ 0.0288(epoch 11 最优)
### 3.4 训练链路缺陷修复
归一化默认开启后暴露潜伏 bug:`train.py` 将 action_stats 保存到 checkpoint_dir 后,cfg 只留裸文件名 "action_stats.npy",val_ds 按 cwd 解析 → `FileNotFoundError`。修复:验证集构建用完整路径,checkpoint 配置存裸文件名(推理端按 checkpoint 目录解析)。同步修正 `test_warn_init_from_mismatch` 对旧默认值的隐式依赖(commit `7ec8c9d`,141 测试全过)。
### 3.5 推理冒烟
模型 B 加载正常,接口契约 `frame_len=3 / history_len=5 / action_dim=18 / imu_dim=None` 与 pybot 闭环鸭子类型约定一致;单次推理 ~84ms(x64 CPU)。真实帧+真实历史预测 t=5 动作:s1 误差 1.78°(模型 A 为 4.39°),数值范围合理。
---
## 4. R4 闭环评估
### 4.1 聚合指标对比
| 指标 | 模型 A(未归一化) | 模型 B(归一化) |
|---|---|---|
| overall_mse | 6446.7 | **2445.4**(2.6× 优) |
| oob_rate | 100% | **28.1%** |
| smoothness_ratio | 0.147(近常数输出,过度平滑) | **1.047**(≈真值变化,正常) |
| error_growth | 0.005 | 0.082(均 <1,无发散) |
### 4.2 逐 episode 对比(闭环滚动 mean_mse)
| Episode | 步数 | 模型 A | 模型 B | 比值 |
|---|---|---|---|---|
| 0000 stand | 76 | 25.5 | 134.0 | 5.26× 恶化 |
| 0001 jump | 22 | 20.5 | 221.1 | 10.78× 恶化 |
| **0002 left-hand** | 118 | 21252.2 | **6849.7** | **0.32× 改善** |
| 0003 left-turn | 53 | 276.0 | 641.0 | 2.32× 恶化 |
| 0004 lie-down | 6 | 35.2 | 46.9 | 1.33× 恶化 |
| **0005 rght-hand** | 118 | 21340.0 | **8022.9** | **0.38× 改善** |
| 0007 stdd | 41 | 49.8 | 115.9 | 2.33× 恶化 |
| 0008 t0 | 206 | 644.5 | 553.3 | 0.86× 改善 |
| 0009-0012 tilt-* | 41×4 | 29.8-34.2 | 59.6-751.7 | 1.74-25.12× 恶化 |
### 4.3 误差主导列定位
对 left-hand 逐列分析(模型 A,mean_mse 21252):
- **舵机列(0-9)预测全部在合理范围**(35-85°),无物理危险
- **列 10(spk.F)是 MSE 主导源**:该脚本真值 10→1000Hz,模型预测 ~340Hz 折中值 → 单列平方误差 10万-44万
- 列 8、12-17 为恒值列(s9=90°、保留位 0),预测有微小偏差但绝对值无害
### 4.4 指标退化发现
1. **oob_rate=100%(模型 A)不可信**:数据集中 7 个恒值列(span=1e-6)经验界宽度为零,任何微小偏差都计越界。模型 B 的 28.1% 改善主要来自 spk.F 落入经验界,指标本身仍需修正(见开发方案 §3)
2. **error_growth 有误导性**:不同长度 episode 混叠使曲线非单调(中期尖峰来自 123 帧 hand episode,结束后回落),末段/首段比值掩盖了中期爆涨
### 4.5 核心结论
归一化把各列方差拉平,隐式实现了"全列等权",带来 spk.F 跟踪改善(hand 类 3× 提升),代价是舵机角跟踪精度下降(简单动作 1.3-25× 恶化)。缩放方式与列重要性是**正交**的两个维度,需要显式逐列权重解耦——详见开发方案 `actpred_action_norm_weighting_plan_20260814.md`。
---
## 5. 产物与提交
| 产物 | 位置 |
|---|---|
| 数据集(865 帧 / 804 样本) | `data/pybot_record/`(231MB,不入 git) |
| 模型 A checkpoint | `actpred/checkpoints/recorded_run/` |
| 模型 B checkpoint | `actpred/checkpoints/recorded_run_norm/` |
| 评估报告 JSON | `data/eval_recorded.json` / `data/eval_recorded_norm.json` |
| 批录制脚本 | `data/record_all.sh` |
相关提交:`af0ff56`(record_stand.mt)、`7ec8c9d`(stats 路径 bug 修复)。
## 6. 局限
- 帧为合成噪声、IMU 全零:视觉/姿态通路未学习到真实信号,结论仅覆盖动作动力学部分
- 验证集仅 1 个 episode(41 样本):模型选择噪声大,最优 epoch 有偶然性
- 两个模型各 13 万步级样本、CPU 训练 2-3 小时:结论在更大数据与实机数据上需复核
@@ -0,0 +1,191 @@
# pybot & actpred 审计报告(第二版 —— 归一化+逐列加权 落地后)
> 审查日期:2026-08-14 (Asia/Shanghai)
> 审查人:AI Assistant(SOLO Coder)
> 审查范围:`pybot/`(机器人运行时)与 `actpred/`(动作预测)两模块
> 代码基线:归一化+逐列加权方案落地后(12 个文件改动,531 行新增)
>
> 本报告对照三份历史审计报告追踪修复状态,并审查本轮新增代码。
---
## 一、审计结论摘要
| 级别 | 新发现 | 历史遗留 | 说明 |
|------|--------|---------|------|
| **P0(崩溃/数据损坏)** | 2 | 0 | 见 §二 |
| P1(功能风险) | 0 | 5 | 见 §三 |
| P2(代码质量) | 3 | 9 | 见 §四 |
**重点:2 个 P0 必须在提交前修复。**
---
## 二、P0 新发现
### P0-1 pybot HailoPredictor 缺失 normalize_mode 传递
**位置**:[pybot/main.py:180-183](../../pybot/pybot/main.py#L180-L183)
```python
return _P(checkpoint, stats_path=stats_path,
predict_delta=predict_delta,
pred_token=pred_token)
```
**问题**:`HailoPredictor.__init__` 的 `normalize_mode` 参数默认 `"zscore"`,pybot 调用时未传入此参数。对于 `fixed_range` 训练的 HEF,推理端应按 `action_norm.py` 固定物理范围对历史动作做 min-max→[0,1] 归一化,但 pybot 未传参导致 `_fixed_range=False`,历史动作以原始量纲送入 HEF。
**影响**:`fixed_range` 模式训练的 HEF 在 pybot 闭环中推理输出完全错误(舵机会被推到安全范围外)。`zscore` 模式不受影响(默认值偶然正确)。
**修复建议**:pybot 从同目录 checkpoint 读取 `normalize_mode` 并传入 `HailoPredictor`:
```python
# 在 _load_predictor 中,从 companion checkpoint 读取 normalize_mode
normalize_mode = "zscore"
for name in ("pretrain_best_inference.pt", "best_inference.pt"):
ckpt_path = os.path.join(hef_dir, name)
if os.path.exists(ckpt_path):
try:
import torch
ck = torch.load(ckpt_path, map_location="cpu", weights_only=True)
normalize_mode = ck.get("config", {}).get("data", {}).get(
"normalize_mode", "zscore")
except Exception:
pass
break
return _P(checkpoint, stats_path=stats_path,
predict_delta=predict_delta,
pred_token=pred_token,
normalize_mode=normalize_mode)
```
### P0-2 DataConfig normalize_actions 默认值变更,与 README 不一致
**位置**:[actpred/config.py:14](../../actpred/config.py#L14)
```python
normalize_actions: bool = True # 旧默认值:False
```
**问题**:默认值从 `False` 改为 `True`,但 [README.md 配置表](../../actpred/README.md#L163) 仍标注 `False`。CLI 不经 `--normalize-actions` 参数时,`apply_overrides` 会使用 dataclass 默认值 `True`,导致行为变更:
- 旧脚本(`bash _run.sh train --data-dir ./data --epochs 100`)之前不归一化,现在开启 zscore 归一化,且会自动计算 `action_stats.npy`。
- 对预训练(`pretrain_mt.py`)同样影响:预训练数据从 .mt 脚本提取,zscore 归一化后分布与旧 checkpoint 不同。
**影响**:所有无 `--normalize-actions` 或无 `--no-normalize-actions` 的训练命令行为改变;旧 checkpoint 做 `--init-from` 时 `warn_init_from_mismatch` 会警告但不会阻止。
**修复建议**:二选一:
- **方案 A**:恢复默认值 `False`(保持向后兼容,推荐);
- **方案 B**:改 README 为 `True`,并在 CHANGELOG 中标注 breaking change。
---
## 三、P1 历史遗留(未修复)
| # | 来源 | 位置 | 问题 | 优先级 |
|---|------|------|------|--------|
| P1-3 | 闭环审计 | [actpred/mt_parser.py:33](../../actpred/mt_parser.py#L33) | `read_text(encoding="utf-8")` 不剥离 BOM,`@Action` 为首块时表头列名可能粘附 `` | 低(低概率触发) |
| P2-a | 闭环审计 | [pybot/main.py:143](../../pybot/pybot/main.py#L143) | `_load_predictor` 仅用 `endswith(".hef")` 区分后端,大小写敏感,无 ONNX 分支 | 中 |
| R2-1 | actpred 审计 | [actpred/train.py:270](../../actpred/train.py#L270) | NaN val_loss 永久阻断 best checkpoint 保存(`NaN < x` 恒 False) | 中 |
| R1-1 | actpred 审计 | [actpred/pretrain_mt.py:185](../../actpred/pretrain_mt.py#L185) | `cfg.data.data_dir = args.mt_dir` 覆盖,`--save-config` 导出语义错误 JSON | 低 |
| R2-7 | actpred 审计 | [actpred/_run.sh:14-16](../../actpred/_run.sh#L14-L16) | 未知子命令静默回退到 `predict.py`,拼写错误难以发现 | 低 |
---
## 四、P2 历史遗留(未修复)
| # | 来源 | 位置 | 问题 |
|---|------|------|------|
| P2-b | 闭环审计 | pybot/robot.py 等 3 处 | numpy 在闭环路径 3 个函数各自 `import` |
| P2-c | 闭环审计 | pybot/tests/test_loop.py | 未覆盖闭环 + recorder 集成、predictor 异常清理 |
| P2-d | 闭环审计 | actpred/dataset.py | 图像 Resize 不保持宽高比 |
| P2-e | 闭环审计 | actpred/train.py | resume 时改 `--epochs` 会重算 lr scheduler |
| P2-f | 闭环审计 | actpred/export_deploy.py | ManualAttnBlock 依赖 PyTorch 内部布局约定 |
| R2-3 | actpred 审计 | actpred/train.py | 空验证集时 `best.pt` 从不创建 |
| R2-4 | actpred 审计 | actpred/_set.sh | CUDA 版 torch 误装风险 |
| R1-2 | actpred 审计 | predict/onnx_infer/hailo_infer | `parse_action_history` 三处重复 |
| R2-9 | actpred 审计 | actpred/predict.py | `--action-dim` override 参数无用且是陷阱 |
---
## 五、已完成修复的审计项
| # | 来源 | 修复内容 |
|---|------|---------|
| P1-1 | 闭环审计 | 闭环节拍 overrun 告警已实现([robot.py:307-320](../../pybot/pybot/robot.py#L307-L320)),含比例统计 |
| P1-2 | 闭环审计 | 闭环帧源改走 `_loop_recent_frames`([robot.py:323-338](../../pybot/pybot/robot.py#L323-L338)),从守护线程队列取帧 |
| P1-4 | 闭环审计 | DataRecorder output_dir 已有 `mkdir(parents=True, exist_ok=True)`([recorder.py:46](../../pybot/pybot/recorder.py#L46)) |
| R2-2 | actpred 审计 | ActionDataset 外部 stats 文件已补 shape 校验([dataset.py:92-95](../../actpred/dataset.py#L92-L95)) |
---
## 六、本轮新增代码审查(归一化+逐列加权方案)
### 6.1 action_norm.py — 通过,有 1 处 P2 建议
**总体评价**:常量表清晰、文档完善、clamp 防御到位。18 个单测覆盖正常值/边界/越界/多维度。
**P2-a1(建议)**:`default_column_weights` 的 `action_dim` 默认为 18,但与 `action_bounds` 的行为不同——`action_bounds` 对 `action_dim > 18` 自动补零,`default_column_weights` 对此类情况返回全零权重(因为 `min(10, action_dim)` 和 `min(12, action_dim)` 仅覆盖前 12 列,其余列保持 `np.zeros` 初始值)。建议对齐行为:`action_dim > 18` 时对超出列也赋默认权重(如 0.2)。
### 6.2 config.py — 通过,有 1 处 P2
**总体评价**:`normalize_mode` 和 `column_weights` 的 CLI 解析、值域校验、checkpoint 兼容性警告均实现完整。
**P2-a2(建议)**:`warn_init_from_mismatch` 中 `init_mode = init_data.get("normalize_mode", "zscore")` 对旧 checkpoint 正确回退。但旧 checkpoint 的 `normalize_actions=False` 时,`normalize_mode` 无意义,此时警告 `init_mode != current.data.normalize_mode` 是误报。建议仅在 `init_norm and current.data.normalize_actions` 时比较 `normalize_mode`。
### 6.3 dataset.py — 通过
固定范围归一化集成正确:`_fixed_range` 标志位在 `__init__` 中设置,`_norm` 方法按标志位分发。stats shape 校验(R2-2 修复)已补。
### 6.4 train.py — 通过,有 1 处 P2 行为变更
**总体评价**:`_weighted_mse` 实现正确,`weights.mean()` 归一化保证加权 Loss 与无权重 Loss 量级可比。`column_weights` 为 None 时自动取 `default_column_weights`。
**P2-a3(行为变更)**:`column_weights` 现在**始终生效**(None 时取默认值),即使 `normalize_actions=False`。这意味着不归一化训练的模型也会按列加权损失——模型对舵机列(权重 1.0)的优化力度高于 spk 列(0.2)。这并非 bug,但行为变更需在 README 注明。
### 6.5 evaluate.py — 通过
物理界 OOB 与 `action_norm.py` 常量表共用(`_action_bounds` 调用 `action_bounds`)。error_growth 按 episode 分别计算取中位数,消除长短 episode 混杂偏差。实现正确。
### 6.6 推理/部署链 — 通过,P0-1 除外
| 文件 | 审查结论 |
|------|---------|
| predict.py | `_fixed_range` 从 checkpoint config 读取,normalize/denormalize 正确 |
| onnx_infer.py | `normalize_mode` 构造参数,predict 中 normalize/denormalize 正确 |
| hailo_infer.py | `normalize_mode` 构造参数,predict 中 normalize/denormalize 正确 |
| gen_calibration.py | `fixed_range` 模式校准数据走 `normalize_action` 变换,与推理端一致 |
| export_deploy.py | `check_token_ranges` 按 `normalize_mode` 在正确空间采样,stats_path 逻辑正确 |
**唯独 pybot 的 `_load_predictor` 未传 `normalize_mode` 给 HailoPredictor(P0-1)**。
### 6.7 test_actpred.py — 通过
新增 `TestFixedRangeNorm` 类 18 个测试用例,覆盖:
- 已知值归一化(舵机中位/边界、spk 中位)
- 越界 clamp(>180→1.0、<0→0.0)
- denormalize 往返一致性
- 默认列权重结构
- 自定义列权重
- 多维度批次归一化
- 全 0 输出验证
全量 148 passed / 11 skipped。
---
## 七、修复优先级
| 优先级 | 项 | 理由 |
|--------|-----|------|
| **P0** | P0-1 HailoPredictor normalize_mode 缺失 | `fixed_range` HEF 在 pybot 闭环中推理完全错误 |
| **P0** | P0-2 normalize_actions 默认值变更 | 所有无此参数的命令行为改变,README 不一致 |
| P1 | R2-1 NaN val_loss | 训练健壮性,一行 isfinite 修复 |
| P1 | P2-a 后端检测 | 路径大小写敏感,ONNX 无分支 |
| P2 | P2-a2 误报警告 | 旧 checkpoint 的不必要警告 |
| P2 | P2-a1 action_dim>18 | 未来扩展性 |
| P2 | P2-a3 行为变更文档 | README 注明 column_weights 始终生效 |
---
*本报告为归一化+逐列加权方案落地后的专项审计。建议先修复 2 个 P0 再提交。*
@@ -0,0 +1,168 @@
# pybot & actpred 审计报告(第三版 —— v2 修复批次落地后)
> 审查日期:2026-08-14 (Asia/Shanghai)
> 审查人:AI Assistant(SOLO Coder)
> 审查范围:`pybot/`(机器人运行时)与 `actpred/`(动作预测)两模块
> 代码基线:v2 审计修复批次后(commit 85bf68f,工作区干净)
> 测试状态:pybot 88 passed;actpred 148 passed / 18 skipped(Windows 开发机,全绿;
> ONNX 类 18 项因本机无 onnxruntime/onnxscript 跳过,在 Pi 上运行)
>
> 本报告对照 v2 报告逐项核对修复状态,并给出本轮新发现。
>
> **2026-08-14 更新:P0-2(方案 B)、两个新 P1、P2-a 残余、P2-a3 已修复;
> 3 项新 P2(验收脚本 fixed_range / diff 断言 / overrun 节流)与 5 项遗留 P2
> (P2-a1/P2-c 残余/P2-d/P2-e/P2-f/R2-4)亦全部闭环,附回归测试(pybot +2 / actpred +7)。
> P0/P1/P2 全部清零,仅剩 2 项 P3(边缘/环境便利)。**
---
## 一、审计结论摘要
| 级别 | 新发现 | 历史遗留(v2 未修复) | 说明 |
|------|--------|----------------------|------|
| ~~P0(崩溃/数据损坏)~~ | 0 | ~~1~~ **0(P0-2 已按方案 B 修复)** | 见 §三 |
| ~~P1(功能风险)~~ | ~~2~~ **0(均已修复)** | ~~1~~ **0(P2-a 已闭环)** | 见 §四 |
| ~~P2(代码质量)~~ | ~~3~~ **0(均已修复)** | ~~5~~ **0(全部闭环)** | 见 §五/§六 |
| P3(边缘/环境) | 2 | 0 | 见 §五 |
**重点:P0-2 曾随 85bf68f 未修复提交,本轮已按方案 B 闭环;两个新 P1、P2-a 残余与全部 P2 已于本轮回合修复。**
---
## 二、v2 修复项回归核对(13 项)
| # | v2 级别 | 状态 | 验证 |
|---|---------|------|------|
| P0-1 HailoPredictor 缺 normalize_mode | P0 | ✅ 已修复 | [main.py:163-193](../../pybot/pybot/main.py#L163-L193) 从 companion checkpoint 读取并传入 |
| R2-1 NaN val_loss 阻断 best | P1 | ✅ 已修复 | [train.py:270-274](../../actpred/train.py#L270-L274)、[pretrain_mt.py:361-364](../../actpred/pretrain_mt.py#L361-L364) isfinite 守卫,NaN 走 patience 早停 |
| P2-a 后端检测大小写敏感 | P1 | ✅ 已修复 | [main.py:144-146](../../pybot/pybot/main.py#L144-L146) 已 `.lower()`;v3 补齐 `.onnx → OnnxPredictor` 分支(归一化模式经元数据自动识别,zscore 缺 stats 时告警) |
| P1-3 mt_parser BOM | P1 | ✅ 已修复 | [mt_parser.py:33](../../actpred/mt_parser.py#L33) `utf-8-sig` |
| R1-1 pretrain data_dir 覆盖 | P1 | ✅ 已修复 | pretrain_mt.py 不再赋值 `cfg.data.data_dir` |
| R2-7 _run.sh 静默回退 | P1 | ✅ 已修复 | [_run.sh:17-20](../../actpred/_run.sh#L17-L20) 未知子命令报错退出 |
| P2-a2 init-from 误报警告 | P2 | ✅ 已修复 | [config.py:232](../../actpred/config.py#L232) 仅在双方归一化开启时比较 normalize_mode |
| R2-9 predict.py --action-dim 陷阱 | P2 | ✅ 已修复 | predict.py 已移除该参数,统一用 `predictor.action_dim` |
| R1-2 parse_action_history 三处重复 | P2 | ✅ 已修复 | 三推理文件均从 [utils.py](../../actpred/utils.py) 导入 |
| R2-3 空验证集无 best.pt | P2 | ✅ 已修复 | [train.py:309-324](../../actpred/train.py#L309-L324) 保底保存最终权重 |
| P2-c 闭环测试覆盖 | P2 | ✅ 已修复 | test_loop.py 含 recorder 集成 + 异常清理路径用例(test_predictor_exception_still_flushes_recorder) |
| P2-b numpy 三处 lazy import | P2 | ✅ 已修复 | 顶层导入曾破坏零依赖(v3-P1-1),已恢复函数级 lazy import 并加守卫测试 |
| P0-2 normalize_actions 默认值 | P0 | ✅ 已修复 | [README.md:170](../../actpred/README.md#L170) 更正为 `True` 并注明行为变更(方案 B,见 §三) |
---
## 三、P0 遗留
### P0-2 DataConfig normalize_actions 默认值与 README 不一致 —— ✅ 已修复(2026-08-14,方案 B)
**修复**:[README.md:170](../../actpred/README.md#L170) 默认值更正为 `True`,
并注明"2026-08-14 起默认开启——复现旧行为需显式 `--no-normalize-actions`"。
代码(config.py:14 `True`)、测试、训练实践此前均已按 `True` 落地,文档对齐即闭环。
---
## 四、P1 新发现
### v3-P1-1 pybot 零第三方依赖承诺被打破(85bf68f 修 P2-b 的副作用)—— ✅ 已修复(2026-08-14)
**修复**:robot.py 顶层 `import numpy` 移除,7 个 numpy 使用函数(run_loop /
_loop_cold_start / _execute_action_vec / _read_imus / _pad_imu /
_build_action_vec / _grab_frame)恢复函数内 lazy import,并在导入块处
注明约定禁止恢复顶层导入。新增回归测试
`test_loop.py::test_robot_importable_without_numpy`(子进程屏蔽 numpy
验证模块可导入)。纯脚本路径零依赖承诺恢复,README 无需变更。
**原问题**:~~main.py 无条件 `from .robot import RobotStruct`,numpy 成为硬依赖。~~
### v3-P1-2 OnnxPredictor 不支持 predict_delta,ONNX 无训练配置元数据 —— ✅ 已修复(2026-08-14)
**修复**:
- [export_onnx.py:46-63](../../actpred/export_onnx.py#L46-L63) 导出后写入
`metadata_props`(normalize_mode / predict_delta / imu_dim / action_dim;
未归一化训练记 `normalize_mode="none"`);
- [onnx_infer.py:75-87](../../actpred/onnx_infer.py#L75-L87) 构造时从元数据
自动识别两参数(显式传参优先),[predict():174-176](../../actpred/onnx_infer.py#L174-L176)
在归一化空间加回 history 末步(与 predict.py/hailo_infer 同语义);
- CLI `--normalize-mode` 缺省自动识别、新增 `--predict-delta`;
- README 部署注意已更正(含旧版无元数据 ONNX 的回退说明);
- 新增 TestOnnxMetadata 7 用例(元数据写入/回读/覆盖/残差加回数值),
本机无 onnxruntime 跳过,随 Pi 环境运行。
**原问题**:~~残差训练模型经 export_onnx.py 导出后,ONNX 推理输出被当作绝对动作——静默错误。~~
---
## 五、P2/P3 新发现
### v3-P2-1 验收脚本链未适配 fixed_range —— ✅ 已修复(2026-08-14)
[_npu_test.py:65-76](../../actpred/_npu_test.py#L65-L76)、[_onnx_validate.py:28-42](../../actpred/_onnx_validate.py#L28-L42)
均改为按 checkpoint config 的 `normalize_mode` 分支:zscore 照旧加载
action_stats.npy;fixed_range 走 action_norm min-max 且免 stats 文件;
_npu_test 另向 HailoPredictor 透传 normalize_mode、torch 参考输出按同
模式反归一化后再对比。
### v3-P2-2 导出一致性校验只打印不拦截 —— ✅ 已修复(2026-08-14)
[export_deploy.py:288-292,303-307](../../actpred/export_deploy.py#L288-L292)
与 [export_onnx.py:82-85](../../actpred/export_onnx.py#L82-L85) 新增
`MAX_TOLERATED_DIFF = 1e-4` 阈值断言,超限 `sys.exit` 拦截(P2-f 的
ManualAttnBlock 布局约定风险一并闭环)。
### v3-P2-3 run_loop overrun 逐步告警日志噪音 + 建议阈值偏离约束 —— ✅ 已修复(2026-08-14)
[robot.py:310-317](../../pybot/pybot/robot.py#L310-L317) 逐步告警节流为
第 1 次 + 每 100 次汇总;[robot.py:324-332](../../pybot/pybot/robot.py#L324-L332)
最终建议仅在 overrun 比例 >10% 时输出(否则 info 级汇总),对齐工程约束。
### v3-P3-1 dfc_compile.py --output 无 .hef 后缀时产物互相覆盖
**位置**:[dfc_compile.py:172-173](../../actpred/dfc_compile.py#L172-L173)
`args.output.replace(".hef", "_optimized.har")` 在无后缀时返回原路径,`shutil.copy(har_opt, ...)` 把 HAR 写到刚复制的 HEF 同路径,静默损坏产物。建议校验 `--output` 必须以 `.hef` 结尾。
### v3-P3-2 pybot Windows 测试依赖命令行 fcntl mock
**位置**:[pybot/hd/i2c.py:14](../../pybot/pybot/hd/i2c.py#L14)
顶层 `import fcntl`(Unix-only),Windows 下 conftest 自身 import i2c 即失败,需命令行注入 `sys.modules['fcntl']` mock 才能跑测试(87 passed 即以此方式验证)。建议在 conftest.py 内置同等 mock(一行,先于 i2c import),消除外部依赖。
---
## 六、v2 遗留 P2(已全部修复,2026-08-14)
| # | 位置 | 问题 | 处理 |
|---|------|------|------|
| P2-a1 | [action_norm.py:55-66](../../actpred/action_norm.py#L55-L66) | ~~`default_column_weights` 对 action_dim>18 的超出列权重恒 0~~ | ✅ 扩展列默认 1.0(新执行器须参与训练),补 20 列断言 |
| P2-a3 | [README.md:172](../../actpred/README.md#L172) | ~~column_weights 始终生效(含不归一化训练)未在文档注明~~ | ✅ 已随 P0-2 一并修复 |
| P2-c 残余 | test_loop.py | ~~predictor 异常清理路径无用例~~ | ✅ 新增 test_predictor_exception_still_flushes_recorder(异常传播 + finally 落盘 1 步) |
| P2-d | [dataset.py:46-49](../../actpred/dataset.py#L46-L49) | ~~Resize 不保持宽高比~~ | ✅ 注明有意为之(训练/推理同口径,变更须同步重训),不改行为 |
| P2-e | [train.py:248-251](../../actpred/train.py#L248-L251) | ~~resume 时 scheduler T_max 恢复旧值~~ | ✅ 语义已在代码注释 + README --resume 行明确 |
| P2-f | export_deploy.py | ~~ManualAttnBlock 依赖 PyTorch in_proj 内部布局~~ | ✅ 随 v3-P2-2 阈值断言闭环(布局失效即拦截退出) |
| R2-4 | [_set.sh:19-28](../../actpred/_set.sh#L19-L28) | ~~CUDA 版 torch 误装风险~~ | ✅ 安装后断言 `torch.version.cuda is None`,误装即拦截并给出修复命令 |
---
## 七、修复优先级
| 优先级 | 项 | 状态 |
|--------|-----|------|
| ~~P0~~ | P0-2 README 与默认值对齐(方案 B) | ✅ 已修复 |
| ~~P1~~ | v3-P1-1 robot.py numpy lazy import | ✅ 已修复(附子进程回归测试) |
| ~~P1~~ | v3-P1-2 ONNX predict_delta + 元数据 | ✅ 已修复(全链路 + 7 用例) |
| ~~P2~~ | v3-P2-1 验收脚本 fixed_range 适配 | ✅ 已修复 |
| ~~P2~~ | v3-P2-2 导出 diff 阈值断言 | ✅ 已修复(P2-f 一并闭环) |
| ~~P2~~ | v3-P2-3 overrun 告警节流 + 10% 阈值 | ✅ 已修复 |
| ~~P2~~ | P2-a1/P2-c 残余/P2-d/P2-e/R2-4 | ✅ 全部闭环(见 §六) |
| P3 | v3-P3-1 dfc_compile --output 后缀校验 / v3-P3-2 conftest 内置 fcntl mock | 未修复(边缘/环境便利) |
---
## 八、本轮复核确认无问题的关键路径
- **训练链**:weighted MSE 量级归一、scheduled sampling detach 截断、空验证集兜底、stats 训练集-only 计算(含 history∪target 并集防零方差爆炸)、文件级划分防泄漏——均正确。
- **推理链三后端**:normalize/denormalize 对称、predict_delta 加回在归一化空间、imu_dim=None 鸭子类型兼容旧 checkpoint、NaN/Inf 输出兜底——均正确。
- **部署链**:HAR 输入连通性双阶段检查、pred_token 抖动校准、按 input_layerN 排序绑定、量化网格 dequant(0) 判定帧归一化、token 量级导出前拦截——均正确。
- **pybot 闭环**:守护线程队列帧源、冷启动零历史、finally 落盘 recorder、执行前 isfinite 守卫、Speaker.delay 剔除防阻塞——均正确。
---
*本报告为 v2 修复批次后的回归+新发现审计。建议优先闭环 P0-2(文档一行)与两个新 P1。*
+17 -3
View File
@@ -416,9 +416,9 @@ function renderMarkdown(element, content) {
html: false, html: false,
highlight: function(code, lang) { highlight: function(code, lang) {
if (lang && hljs.getLanguage(lang)) { if (lang && hljs.getLanguage(lang)) {
return `<pre><code class="hljs language-${lang}">${hljs.highlight(code, {language: lang}).value}</code></pre>` return `<pre><code class="hljs language-${lang}"><div style="font-size: 11px;">${lang}</div>${hljs.highlight(code, {language: lang}).value}</code></pre>`
} else { } else {
return `<pre><code class="hljs">${md.utils.escapeHtml(code)}</code></pre>` return `<pre><code class="hljs"><div style="font-size: 11px;">${lang}</div>\n${md.utils.escapeHtml(code)}</code></pre>`
} }
} }
}); });
@@ -813,12 +813,26 @@ var editing_id = null; /* 编辑模式:正在编辑的节点 id,null 表示
var moving_node = null; /* 移动模式:待移动的节点 {id, pid, title, content},null 表示未在移动 */ var moving_node = null; /* 移动模式:待移动的节点 {id, pid, title, content},null 表示未在移动 */
/* ===== 输入框自动高度调整 ===== */ /* ===== 输入框自动高度调整 ===== */
/* 中文输入法 composition 期间每个拼音字母都会触发 input;若每次都做塌陷测量,
* 页面总高度反复变化,滚到页面底部输入时 scrollTop 被浏览器钳制又回弹,
* 表现为页面位置反复横跳。故 composition 中跳过,compositionend 后统一调整一次。 */
var _content_composing = false;
function autoResizeTextarea(el) { function autoResizeTextarea(el) {
var doc = document.scrollingElement || document.documentElement;
var prevTop = doc.scrollTop;
el.style.height = 'auto'; el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 400) + 'px'; el.style.height = Math.min(el.scrollHeight, 400) + 'px';
el.style.overflowY = el.scrollHeight > 400 ? 'auto' : 'hidden'; el.style.overflowY = el.scrollHeight > 400 ? 'auto' : 'hidden';
/* 塌陷测量会触发整页同步布局:若页面瞬时变矮导致滚动位置被钳制,恢复原位 */
if (doc.scrollTop < prevTop) doc.scrollTop = prevTop;
} }
input_note_content.addEventListener('input', function() { input_note_content.addEventListener('compositionstart', function() { _content_composing = true; });
input_note_content.addEventListener('compositionend', function() {
_content_composing = false;
autoResizeTextarea(input_note_content);
});
input_note_content.addEventListener('input', function(e) {
if (_content_composing || (e && e.isComposing)) return;
autoResizeTextarea(input_note_content); autoResizeTextarea(input_note_content);
}); });
function resetTextareaHeight() { function resetTextareaHeight() {
+69
View File
@@ -0,0 +1,69 @@
# Prerequisites
*.d
# Compiled Object files
*.slo
*.lo
*.o
*.obj
# Precompiled Headers
*.gch
*.pch
# Linker files
*.ilk
# Debugger Files
*.pdb
# Compiled Dynamic libraries
*.so
*.dylib
*.dll
*.so.*
# Fortran module files
*.mod
*.smod
# Compiled Static libraries
*.lai
*.la
*.a
*.lib
# Executables
*.exe
*.out
*.app
# Build directories
build/
Build/
build-*/
# CMake generated files
CMakeFiles/
CMakeCache.txt
cmake_install.cmake
#Makefile
install_manifest.txt
compile_commands.json
# Temporary files
*.tmp
*.log
*.bak
*.swp
# vcpkg
vcpkg_installed/
# debug information files
*.dwo
# test output & cache
Testing/
.cache/
+25
View File
@@ -0,0 +1,25 @@
VERSION := 1.0.0
FRAMEWORK_NAME := us
LIB_NAME := libus_memdb
INSTL_TDIR := /lib
INCLU_TDIR := /usr/include
INCLU_SDIR := inc
CPP := g++
INCS := -I inc
CPPFLAGS := --std=c++20 -O3 -pipe -Wall -Werror
LIBS :=
BUILD_TDIR := build
$(shell mkdir -p $(BUILD_TDIR))
install:
@echo "install UniServe_libmemdb..."
@mkdir -vp $(INCLU_TDIR)/${FRAMEWORK_NAME}
@cp -vf $(INCLU_SDIR)/$(FRAMEWORK_NAME)/memdb.hpp $(INCLU_TDIR)/$(FRAMEWORK_NAME)/
@echo "done."
uninstall:
@echo "uninstall UniServe_libmemdb..."
@rm -vf $(INCLU_TDIR)/$(FRAMEWORK_NAME)/memdb.hpp
@echo "done."
+5
View File
@@ -0,0 +1,5 @@
# UniServe_libmemdb
UniServe Framework memdb Library
# Dependancy
libmemdb-dev
+3
View File
@@ -0,0 +1,3 @@
#!/bin/bash
cd "$(dirname "$0")" || exit
make install
+212
View File
@@ -0,0 +1,212 @@
#pragma once
#include <list>
#include <unordered_map>
#include <chrono>
#include <set>
#include <optional>
#include <algorithm>
#include <utility>
template <typename T, typename U>
class Memdb {
struct memdb_column {
T key;
U value;
std::chrono::system_clock::time_point timestamp;
};
struct index_keys {
std::list <memdb_column>::iterator item;
};
struct index_timestamp {
std::list <memdb_column>::iterator item;
bool operator<(const index_timestamp& a) const {
return this -> item -> timestamp < a.item -> timestamp;
}
};
private:
std::list <memdb_column> db;
std::unordered_map <T, index_keys> keys;
std::multiset <index_timestamp> time_touch;
size_t __size = 0;
int MAX_TIME = 60 * 10; // 10分钟
void flush() {
if (this -> time_touch.empty()) {
return;
}
std::chrono::system_clock::time_point now_time = std::chrono::system_clock::now();
auto it = this -> time_touch.begin() -> item;
while (now_time >= it -> timestamp) {
this -> __size--;
key_t to_be_del_k = it -> key;
this -> db.erase(it);
this -> keys.erase(to_be_del_k);
this -> time_touch.erase(this -> time_touch.begin());
if (this -> time_touch.empty()) {
return;
}
it = this -> time_touch.begin() -> item;
now_time = std::chrono::system_clock::now();
}
}
public:
using key_t = T;
using value_t = U;
static constexpr bool fail = false;
static constexpr bool succ = true;
explicit Memdb() = default;
explicit Memdb(int max_time) : MAX_TIME(std::max(0, max_time)) {}
// 深拷贝:重建索引,保证 keys/time_touch 的迭代器指向副本自身的 list
Memdb(const Memdb& other)
: __size(other.__size), MAX_TIME(other.MAX_TIME) {
this -> db = other.db;
for (auto it = this -> db.begin(); it != this -> db.end(); ++it) {
Memdb::index_keys new_key;
new_key.item = it;
this -> keys.emplace(it -> key, new_key);
Memdb::index_timestamp new_time_touch;
new_time_touch.item = it;
this -> time_touch.insert(new_time_touch);
}
}
Memdb& operator=(const Memdb& other) {
if (this == &other) {
return *this;
}
Memdb tmp(other);
std::swap(this -> db, tmp.db);
std::swap(this -> keys, tmp.keys);
std::swap(this -> time_touch, tmp.time_touch);
std::swap(this -> __size, tmp.__size);
std::swap(this -> MAX_TIME, tmp.MAX_TIME);
return *this;
}
~Memdb() = default;
size_t size() {
this -> flush();
return this -> __size;
}
const std::unordered_map <T, index_keys>& get_keys() const {
return this -> keys;
}
const std::list<memdb_column>& get_db() {
this -> flush();
return this -> db;
}
const std::multiset <typename Memdb <T, U>::index_timestamp>& get_time_touch() const {
return this -> time_touch;
}
bool insert(key_t to_be_ins_k, value_t ins_v) {
this -> flush();
if (this -> keys.find(to_be_ins_k) != this -> keys.end()) {
return this -> fail;
}
std::chrono::system_clock::time_point now_time = std::chrono::system_clock::now();
Memdb::memdb_column new_column;
new_column.key = to_be_ins_k;
new_column.value = ins_v;
new_column.timestamp = now_time + std::chrono::seconds(MAX_TIME);
this -> db.push_back(new_column);
Memdb::index_keys new_key;
new_key.item = std::prev(this -> db.end());
this -> keys.emplace(to_be_ins_k, new_key);
Memdb::index_timestamp new_time_touch;
new_time_touch.item = std::prev(this -> db.end());
this -> time_touch.insert(new_time_touch);
this -> __size++;
return this -> succ;
}
bool remove(key_t to_be_del_k) {
this -> flush();
auto kit = this -> keys.find(to_be_del_k);
if (kit == this -> keys.end()) {
return this -> fail;
}
typename std::list <memdb_column>::iterator it = kit -> second.item;
this -> keys.erase(kit);
auto range = this -> time_touch.equal_range({it});
for (auto sit = range.first; sit != range.second; ++sit) {
if (sit -> item == it) {
this -> time_touch.erase(sit);
break;
}
}
this -> db.erase(it);
this -> __size--;
return this -> succ;
}
bool update(key_t to_be_udt_k, value_t udt_v, bool need_to_udt_timetouch=false) {
this -> flush();
auto kit = this -> keys.find(to_be_udt_k);
if (kit == this -> keys.end()) {
return this -> fail;
}
typename std::list <memdb_column>::iterator it = kit -> second.item;
it -> value = udt_v;
if (need_to_udt_timetouch == true) {
auto range = this -> time_touch.equal_range({it});
for (auto sit = range.first; sit != range.second; ++sit) {
if (sit -> item == it) {
this -> time_touch.erase(sit);
break;
}
}
it -> timestamp = std::chrono::system_clock::now()
+ std::chrono::seconds(MAX_TIME);
Memdb::index_timestamp new_time_touch;
new_time_touch.item = it;
this -> time_touch.insert(new_time_touch);
}
return this -> succ;
}
std::optional<U> search(key_t to_be_sch_k) {
this -> flush();
auto kit = this -> keys.find(to_be_sch_k);
if (kit == this -> keys.end()) {
return std::nullopt;
}
return kit -> second.item -> value;
}
};
+8 -5
View File
@@ -331,10 +331,11 @@ private:
try { try {
task.message = pmsg_recv(task.fd, _config.recv_timeout); task.message = pmsg_recv(task.fd, _config.recv_timeout);
} catch (const MsgException&) { } catch (const MsgException&) {
// 不在此清除 busy:fd 保持 busy 直到 _reap_failed 关闭连接。
// 旧实现提前清除 busy,EOF 可读性会让事件循环为同一 fd 重复
// 投递任务,迟到的失败标记经 fd 复用可能落在新连接上误杀之。
std::lock_guard<std::mutex> flk(_failed_mtx); std::lock_guard<std::mutex> flk(_failed_mtx);
_failed.insert(task.fd); _failed.insert(task.fd);
std::lock_guard<std::mutex> blk(_busy_mtx);
_busy.erase(task.fd);
continue; continue;
} }
} }
@@ -354,11 +355,12 @@ private:
failed = true; failed = true;
} }
if (failed) { if (failed) {
// 失败连接交由 _reap_failed 统一清除 busy 并关闭,
// 不提前恢复 select 监听(同 recv 失败路径,防 fd 复用竞态)。
std::lock_guard<std::mutex> flk(_failed_mtx); std::lock_guard<std::mutex> flk(_failed_mtx);
_failed.insert(task.fd); _failed.insert(task.fd);
} } else {
// 清除 busy 标记,恢复 select 监听 // 清除 busy 标记,恢复 select 监听
{
std::lock_guard<std::mutex> lock(_busy_mtx); std::lock_guard<std::mutex> lock(_busy_mtx);
_busy.erase(task.fd); _busy.erase(task.fd);
} }
@@ -528,6 +530,7 @@ private:
// 事件循环线程不阻塞接收,防 Slowloris DoS CWE-400) // 事件循环线程不阻塞接收,防 Slowloris DoS CWE-400)
{ {
std::lock_guard<std::mutex> blk(_busy_mtx); std::lock_guard<std::mutex> blk(_busy_mtx);
if (_busy.count(fd)) return; // 已有在途任务,防同一 fd 重复投递(fd 复用竞态)
_busy.insert(fd); _busy.insert(fd);
} }
{ {
+75
View File
@@ -0,0 +1,75 @@
# Prerequisites
*.d
# Compiled Object files
*.slo
*.lo
*.o
*.obj
# Precompiled Headers
*.gch
*.pch
# Linker files
*.ilk
# Debugger Files
*.pdb
# Compiled Dynamic libraries
*.so
*.dylib
*.dll
*.so.*
# Fortran module files
*.mod
*.smod
# Compiled Static libraries
*.lai
*.la
*.a
*.lib
# Executables
*.exe
*.out
*.app
# Build directories
build/
Build/
build-*/
# CMake generated files
CMakeFiles/
CMakeCache.txt
cmake_install.cmake
#Makefile
install_manifest.txt
compile_commands.json
# Temporary files
*.tmp
*.log
*.bak
*.swp
# vcpkg
vcpkg_installed/
# debug information files
*.dwo
# test output & cache
Testing/
.cache/
# Databases
*.db*
# unit test binary
test_libmemdb
.memdb.pid
+51
View File
@@ -0,0 +1,51 @@
CPP = g++
CPPFLAGS = -std=c++20 -O3 -Wall -Werror -pipe
INCS = -I ../libmsg/inc -I ../libmemdb/inc
LIBS = -L ../libmsg/build -lus_msg -lboost_json -lboost_program_options -lpthread
RPATH = -Wl,-rpath,'$$ORIGIN/../../libmsg/build'
BUILD = build
TARGET = $(BUILD)/memdb
SRC = src/memdb.cpp
HOST ?= 127.0.0.1
PORT ?= 8011
all: $(TARGET)
$(BUILD):
mkdir -p $(BUILD)
$(TARGET): $(SRC) | $(BUILD)
$(CPP) $(CPPFLAGS) $(INCS) -o $@ $< $(LIBS) $(RPATH)
# ── libmemdb 单元测试 ────────────────────────────────────────────────────
TEST_LIBMEMDB = test_libmemdb
TEST_CPPFLAGS = -std=c++20 -O0 -g -Wall -Werror
$(TEST_LIBMEMDB): test_libmemdb.cpp
$(CPP) $(TEST_CPPFLAGS) -I ../libmemdb/inc -o $@ $<
test: $(TARGET)
@echo "=== memdb unit tests ==="
$(MAKE) $(TEST_LIBMEMDB)
./$(TEST_LIBMEMDB)
@echo ""
@echo "=== memdb RPC integration tests (requires running server) ==="
-python3 test_memdb.py $(HOST) $(PORT)
test-libmemdb: $(TEST_LIBMEMDB)
./$(TEST_LIBMEMDB)
test-memdb: $(TARGET)
@echo "Starting memdb server..."
@./$(TARGET) --host $(HOST) --port $(PORT) --workers 2 --max-time 10 >/dev/null 2>&1 & echo $$! > .memdb.pid
@sleep 1
@python3 test_memdb.py $(HOST) $(PORT); EXIT=$$?; \
kill $$(cat .memdb.pid) 2>/dev/null; rm -f .memdb.pid; exit $$EXIT
clean:
rm -f $(TARGET) $(TEST_LIBMEMDB) .memdb.pid
.PHONY: all clean test test-libmemdb test-memdb
+82 -3
View File
@@ -1,5 +1,84 @@
# memdb — 内存数据库模板(实验) # memdb — 内存缓存数据库
基于 C++ 模板的进程内内存数据库(`include/memdb.hpp`),支持键值列、时间戳、索引与惰性删除。 基于 `libmemdb` 模板库的 JSON RPC 缓存服务,提供键值存储、TTL 过期、并发安全的内存缓存能力。
> **状态:实验性,未完成**。仅含模板头与空 `main`,暂无实际接入方,仅供后续 DB 层设计参考。 ## 架构
```
Memdb<std::string, std::string> (libmemdb 模板:LRU 链表 + 哈希索引 + TTL 过期)
└── MemdbServer (RPC 服务端:路由注册 + 互斥锁线程安全)
└── pmc::net::RpcServer (libmsg JSON RPC 服务端:多客户端 select + 线程池)
```
## 路由
| 路由 | 参数 | 返回 | 说明 |
|------|------|------|------|
| `ping` | — | `"pong"` | 健康检查 |
| `set` | `key`, `value` | `true/false` | 插入键值对(key 已存在返回 false) |
| `get` | `key` | `"value"` / `null` | 查询键值 |
| `del` | `key` | `true/false` | 删除键 |
| `update` | `key`, `value`, `refresh_ttl?` | `true/false` | 更新值,可选刷新 TTL |
| `exists` | `key` | `true/false` | 键是否存在 |
| `size` | — | `int` | 当前条目数 |
| `expire` | `key`, `value` | `true/false` | 更新已有键的值并重置 TTL(键不存在返回 false) |
| `show` | — | `[{key,value},...]` | 导出所有条目 |
## 配置
`ServerConfig` 结构体(`src/memdb.cpp`):
| 字段 | 默认值 | 说明 |
|------|--------|------|
| `listen_host` | `127.0.0.1` | 监听地址 |
| `listen_port` | `8011` | 监听端口 |
| `workers` | `4` | 线程池大小 |
| `max_time` | `600` | 默认 TTL(秒),10 分钟 |
| `backlog` | `128` | listen() backlog(libmsg 默认 10,短连接突刺易丢连接) |
## 构建与运行
**前置依赖**:g++(C++20)、libmsg、libmemdb、boost::json
```bash
# 构建
bash _set.sh
# 运行
bash _run.sh
```
## 客户端示例
```cpp
#include <us/RpcClient.hpp>
pmc::net::RpcClient rpc("127.0.0.1", 8011, "memdb-client");
// 插入
json::object params;
params["key"] = "session:abc123";
params["value"] = R"({"user":"admin","role":"root"})";
rpc.call("set", params); // → true
// 查询
rpc.call("get", {{"key", "session:abc123"}}); // → "{\"user\":\"admin\",...}"
// 检查存在
rpc.call("exists", {{"key", "session:abc123"}}); // → true
// 查看大小
rpc.call("size"); // → 1
```
## 项目结构
```
memdb/
├── src/
│ └── memdb.cpp # MemdbServer + main()
├── Makefile
├── _set.sh
├── _run.sh
└── README.md
```
+4
View File
@@ -0,0 +1,4 @@
#!/bin/bash
set -e
cd "$(dirname "$0")"
exec ./build/memdb --instance "${INSTANCE:-memdb}" --log-level "${LOG_LEVEL:-info}" "$@"
+4
View File
@@ -0,0 +1,4 @@
#!/bin/bash
set -e
cd "$(dirname "$0")"
make
Loaded 100 of 126 files, more files were not shown because too many files have changed in this diff. Show more