Virtual Embryo Challenge更新于 10-03 20:13(北京时间) / 每 5 分钟更新

← 工作原理 · 原文件 notes/architecture/16_llm_relay_fallback.md(Markdown 源文件已渲染;链接到其他文档的会跳转,指向源码的只显示路径)

16. LLM 凭据中继与模型回退链(G49)

一句话:Claude 的凭据(API key 和 Claude Code 订阅登录态)只放在 VM。Spark 经 ssh 反向转发调用 VM 上的中继,只持有中继令牌。每个角色配一条回退链:Token Plan 千问 → 百炼按量千问 → Opus。每次调用记 token,并和 run 轨迹对账。

用户决定(2026-10-03):

  • 凭据只放在 VM;
  • draft 节点用 Opus(claude-opus-5-5),其余用千问;
  • 回退链按上面的顺序;
  • 每次调用都记账;
  • Astra / Codex 只在 VM 上小试,不进 Spark;
  • 尽量复用 VM 上 Claude Code 的订阅登录态。

本文写的代码都已实现并有单元测试。控制器主流程还没有接线(另一个 agent 正在改 controller.py),接线点见 §6。

1. 拓扑

DGX Spark (spark-longxinyang)                                   VM (Arch, aarch64)
┌────────────────────────────────────────────────────┐          ┌──────────────────────────────────────────────┐
│ controller (host)                                   │          │ vec-relay.service  127.0.0.1:18790           │
│  ├─ single-turn roles ── http://127.0.0.1:18790 ───┼──┐       │  /anthropic/...        → api.anthropic.com    │
│  └─ bwrap sandbox (llm_only)                        │  │       │      (api_key backend, gopass key)           │
│      opencode Engineer                              │  │ ssh -R│  /claude-cli/v1/messages → `claude -p`       │
│        HTTP_PROXY=127.0.0.1:3128 (fwd.py)           │  │ 18790 │      (claude_cli backend, subscription login)│
│          │ /run/vec/egress.sock                     │  ├──────►│  /openai/token-plan/... → Token Plan          │
│          ▼                                          │  │       │  /openai/bailian/...    → DashScope 按量      │
│      egress_proxy (host)                            │  │       │  calls.jsonl  ~/.local/share/vec-relay/      │
│        token-plan.cn-beijing... (CONNECT, direct) ──┼──┼──► Token Plan (Spark key, unchanged)              │
│        vec-relay.local=127.0.0.1:18790 (alias) ─────┼──┘       │ vec-relay-tunnel.service: ssh -N -R ... spark│
└────────────────────────────────────────────────────┘          └──────────────────────────────────────────────┘
                 ▲ Spark:22 ◄── VM 127.0.0.1:2201 ◄── Mac reverse tunnel (launchd com.vec.spark-tunnel)
  • 中继只监听 VM 的 loopback。Spark 那一端的 18790 也只绑在 loopback(sshd 默认 GatewayPorts no)。隧道做法和 egress 别名见 spark_env.md §10。
  • Token Plan 千问照旧由 Spark 直连(key 早已在 Spark 上),不走中继。中继里也有 token-plan 上游,供 VM 上的调用和对账测试用。
  • Spark 能直连 api.anthropic.com,但我们不走这条路,凭据不上 Spark。

2. 两种 Claude 后端

后端路由凭据适用限制
claude_cli(订阅)POST /claude-cli/v1/messagesVM 上 claude 的登录态(订阅);子进程环境去掉 ANTHROPIC_API_KEY 等单轮、纯文本进出的角色:Researcher、Compliance、Analyst、Reviewer、报告写手、G44 分析、G50 审计带 tools / tool_choice、stream: true 或非文本块的请求 → 400 relay_unsupported。同时最多 2 个 CLI 进程(--cli-concurrency)
api_key/anthropic/<rest>gopass services/anthropic/api-key(或 $VEC_RELAY_ANTHROPIC_KEY)opencode 的 Engineer 会话(要工具调用、流式)没有 key 时这个后端关闭:返回 503 relay_mode_disabled,/healthz 的 modes.api_key=false

claude_cli 每次调用一次 claude -p --model <m> --output-format json --tools "" --strict-mcp-config --setting-sources project --no-session-persistence --disable-slash-commands --system-prompt <短中性提示>:

  • system 和各轮消息拼成一个 prompt,从 stdin 传入;
  • cwd 是空的临时目录,所以不加载 hooks、插件、MCP;
  • 输出 JSON 的 usage(含 output_tokens_details.thinking_tokens)记入 calls.jsonl,total_cost_usd 记作 cost_usd_equiv(只是参考值,订阅本身不按量计费);
  • 答复包成 Anthropic Message 格式返回;
  • CLI 报 usage limit 时返回 429 relay_cli_quota。

2026-10-03 在 VM 上实测过这套参数:haiku 一问一答,input 408 token,说明 Claude Code 的默认系统提示已经替换掉了。

由此得出的现状(用户还没给 Anthropic API key):

  • 单轮角色现在就能切 Opus,走订阅模式,由 Spark 宿主机上的控制器直接 POST http://127.0.0.1:18790/claude-cli/v1/messages,不经沙箱;
  • Engineer 的 draft 节点在没有 API key 时留在千问。draft 链是 [Opus(api_key), Token Plan 千问],中继回 503 relay_mode_disabled,回退模块把它当额度类错误立即跳过(§4),所以 draft 一直用千问,直到 key 到位、探测成功。

3. 中继(agent/relay/server.py)

  • Python 标准库(ThreadingHTTPServer + http.client),VM 的 /usr/bin/python3,不装额外的包。
  • 鉴权:客户端在 x-api-key 或 Authorization: Bearer 里带中继令牌(gopass services/vec-relay/token,install.sh 随机生成)。客户端的鉴权头和 X-Vec-* 头不会发往上游;上游的 key 由中继注入(Anthropic 用 x-api-key,OpenAI 兼容用 Authorization: Bearer)。
  • 凭据:先查环境变量 VEC_RELAY_<NAME>_KEY / _BASE,再查 gopass show -o,在进程内缓存。上游回 401 时丢掉缓存,下次调用重读。
  • 请求目标:origin-form 和 absolute-form(POST http://vec-relay.local:18790/...,egress 代理原样转来)都接受。
  • 流式:上游响应没有 Content-Length(SSE)时逐块透传(向客户端用 chunked),同时旁路解析 usage 事件。OpenAI 兼容的流式请求如果没带 stream_options,中继补上 {"include_usage": true}。
  • 调用记录 ~/.local/share/vec-relay/calls.jsonl(0600)。每次请求一行,字段有: 从不记录 key、令牌或消息内容。
    • time、relay_id、run、node、role、attempt(取自请求头 X-Vec-Run / X-Vec-Node / X-Vec-Role / X-Vec-Attempt);
    • upstream、backend、path、model、model_reported、stream、status;
    • error_kind:quota / rate_limit / overloaded / auth / bad_request / upstream_5xx / upstream_unreachable / credentials_missing / unsupported / mode_disabled / stream_error;
    • tokens{input(不含缓存读), output, reasoning, cache_read, cache_write};
    • ttfb_s、duration_s、bytes_up / bytes_down、request_id。
  • /healthz:不带令牌时只返回 {"ok": true};带令牌时再返回每个上游有没有 key(布尔值)和 modes。
  • 安装:bash agent/relay/install.sh 生成令牌,写入并启动 vec-relay.service + vec-relay-tunnel.service(systemd 用户单元,模板在 agent/relay/systemd/)。Spark 侧用 bash agent/relay/spark_setup.sh --apply。

4. 回退链(agent/search/model_fallback.py)

配置写在搜索配置的 models: 下。不写 fallback / by_op 时行为不变,lock 也不变:

models:
  researcher:
    model: alibaba-token-plan-cn/qwen3.8-max
    options: {thinking_budget: 8192}
    fallback:
      - {model: vec-relay-bailian/qwen3.8-max, options: {thinking_budget: 8192}}
      - {model: vec-relay-claude-cli/claude-opus-5-5, options: {}}     # 单轮角色:订阅模式(接线点 6.8)
  engineer:
    model: alibaba-token-plan-cn/qwen3.8-max
    options: {thinking_budget: 4096}
    fallback:
      - {model: vec-relay-bailian/qwen3.8-max, options: {thinking_budget: 4096}}
      - {model: vec-relay-anthropic/claude-opus-5-5, options: {}}
    by_op:
      draft:                                                           # 决定 2026-10-03:draft 用 Opus
        model: vec-relay-anthropic/claude-opus-5-5
        fallback: [{model: alibaba-token-plan-cn/qwen3.8-max, options: {thinking_budget: 4096}}]
  fallback_policy: {threshold: 5, quota_minutes: 60}                   # 可省略;默认值取 ops.breaker
  • 健康状态按模型字符串记:同一个模型的健康状态由所有角色、所有算子共享。Engineer 遇到 Token Plan 额度用尽,Researcher 也会跟着离开 Token Plan。
  • 每个模型配一个 ops.Breaker,计数方式和 run 熔断器相同(连续 N 次,或窗口内错误率超过阈值):
    • open 期间跳过该模型;
    • 退避到期后进入 half-open,链回到该模型,下一次调用就是探测;
    • 探测成功就关闭熔断,记一条 model_restore;探测失败则退避时间翻倍(1 → 30 min)。
  • 额度类错误立即跳过,时长 quota_minutes。额度类包括:quota、Arrearage、credit balance、usage limit、relay_mode_disabled、relay_credentials_missing。
  • 触发条件:
    • 额度用尽:立即触发;
    • 429 持续:429 属于提供方错误(ops.PROVIDER_RE),连续 N 次或窗口错误率达阈值即触发;
    • 连续 N 次提供方错误:同上。
  • 事件:链选中的模型变了(select())时:
    • 往 <run_dir>/operator_log.jsonl 写一条,cmd 为 model_fallback 或 model_restore,带 from / to / reason / waits_s;
    • 调 on_event,接线时传入控制器的 ops_event,事件就进了 archive events;
    • 另外每次健康状态变化都发一条 model_health 事件。
  • lock:lock_entry() 返回规范化后的链和策略,写进 lock 顶层的 models_fallback 字段。没有回退时返回 None,lock 不带这个字段。harness.lock_fields 已改:v8 的 lock 有这个键时才把它算进 hash,所以以前的 lock 和不用回退的新 lock,hash 都不变。

5. 失败模式

情形现象结果
Mac 睡眠 / 隧道断 / VM 关机 / 中继挂了egress 别名连 127.0.0.1:18790 失败 → 502;控制器直连得到 ECONNREFUSEDPROVIDER_RE 能匹配 502 / ECONNREFUSED。连续 N 次后该中继模型被跳过,draft 退回 Token Plan 千问,单轮角色退回千问;隧道恢复后经 half-open 探测切回。连接失败是秒级的,代价是 N 次快速失败的调用
没有 Anthropic API key503 relay_mode_disabled立即跳过 quota_minutes,draft 留在千问
Token Plan 额度用尽错误文本里有额度字样立即切百炼。百炼没有 key 时中继回 503 relay_credentials_missing,立即再切 Opus
订阅额度用尽(claude_cli)429 relay_cli_quota按额度处理,退回链里的下一项
429 持续每次都是 429计数达阈值后切下一项,按退避时间探测
整条链都不可用record() 返回 chain_exhausted这时才让 run 熔断器计数,暂停开新节点(接线点 6.3)
流式响应中途断开opencode 报 socket 错误和现在一样,角色调用失败、重试,计为提供方错误
gpg-agent 锁着(gopass 读不出 key)503 relay_credentials_missingkey 读到一次后就缓存在进程里;重启中继前确认 gopass 能读
中继令牌错401 relay_auth配置错误,不应出现;会按提供方错误计数,最后切走

安全:沙箱里的 agent 能读到 per-call 的 auth.json,也就能拿到中继令牌,经 egress 别名自己调用中继。这和 Token Plan key 现在的暴露面一样,而且每次调用都会记进 calls.jsonl。不管怎样,真实的 Anthropic / 百炼 key 都不会离开 VM。

6. 接线点清单(未做;controller.py 由另一个 agent 改)

  1. controller.load_search_config(约 177–199 行):在 models 被重建之前,用原始的 models: 段算出 model_fallback.normalize(raw_models, cfg.get("ops"))。ValueError 转成 SearchError。cfg["models"] 仍然只放主模型,这样不用回退的配置 search_hashes["models"] 不变。
  2. make_search_lock(约 537 行):fb = model_fallback.lock_entry(raw_models, ops);if fb: body["models_fallback"] = fb。harness 已经支持这个字段。另一种做法是把 models_fallback 放进 cfg,由 config 字段一起锁住,但我们没选这个。
  3. Controller.__init__(约 725 行)与 llm_call(约 812–836 行):
    • 初始化:self.fallback = model_fallback.FallbackManager(lock["models_fallback"], run_dir, on_event=self.ops_event);
    • 调用前:sel = self.fallback.select(role, op),其中 op = self.ar.node(node_id)["op"];
    • 把 sel.model / sel.options 交给 runner(见 6.4);
    • 调用后:out = self.fallback.record(role, op, sel.model, res);
    • run 熔断器:只在 out["chain_exhausted"] 或成功时调 self.breaker.record(err, …)。只要链里还有可用模型,错误就只记在该模型的 breaker 上,不暂停整个 run。
  4. roles.LLMRunner.model_cfg / _call_once:
    • 接受本次调用的模型覆盖(例如 call(..., model_override=sel)),CallResult.model 记实际用到的模型;
    • _role_config:provider 是 vec-relay-* 时,往 provider.<p>.options.headers 加 X-Vec-Run(run_id)、X-Vec-Node、X-Vec-Role、X-Vec-Attempt,供中继记账和对账;
    • stall_seconds 按实际模型取。
  5. 凭据镜像:
    • harness.CredentialMirror.prepare 现在只复制一个 provider(cfg["model"]);
    • 改成复制 model_fallback.providers(spec) 里的全部 provider(roles._state 会照搬 mirror 里的 auth.json);
    • vec-relay-* 的条目放的是中继令牌。
  6. allowed_hosts 校验:链里有 vec-relay-* provider 时,sandbox.allowed_hosts 里必须有 vec-relay.local=127.0.0.1:18790,否则 SearchError。
  7. ops.probe_models(约 874 行):
    • 链里的每个模型都探测一次,记进 provider_models;
    • vec-relay-* 不在 models.dev 目录里,改为用中继令牌 GET http://127.0.0.1:18790/healthz,看 modes 和上游 key 在不在;
    • 也可以起一个后台线程,每 60 s 探一次 /healthz,连不上就对该中继的所有模型 record(…, "relay unreachable: ECONNREFUSED")。这样能省掉 §5 第一行那 N 次失败的调用。
  8. 单轮角色走订阅模式:
    • 在 models.<role>.framework 加一个值 relay_cli,扩展 codex_cli.FRAMEWORKS / roles 的分派;
    • 控制器直接 POST http://127.0.0.1:18790/claude-cli/v1/messages,请求体是 {"model": "claude-opus-5-5", "system": spec, "messages": [{"role": "user", "content": prompt}], "max_tokens": …},带上中继令牌和 X-Vec 头;
    • 不起沙箱,没有工具:Researcher 本来就是单轮、没有文件工具;Analyst / Reviewer 需要的上下文改为内联;
    • 回退链里写成 vec-relay-claude-cli/claude-opus-5-5。
  9. tune(tune.py 约 670 行)与 report/write.llm_call:用同一个 select / record 包一层。报告写手、G44 / G50 走 6.8 的订阅路径。
  10. 看板 / 对账:
    • calls.jsonl 在 VM 上,Spark 上的看板要读它,得先同步过去,例如 VM 上加一个 timer:rsync ~/.local/share/vec-relay/calls.jsonl spark:~/vec/dashboard/relay_calls.jsonl;
    • 然后运行 python3 -m agent.records.llm_usage --runs-root ~/vec/runs/formal --relay-log ~/vec/dashboard/relay_calls.jsonl --out …/llm_usage.json。

7. 用量对账(agent/records/llm_usage.py)

  • run 侧:archive 的 llm_calls_json 里每次角色调用(含 tune 的每一轮)一条记录。trajectory_paths 里没有被任何调用记录覆盖的 .jsonl 轨迹,按 opencode 的 step_finish 补算。
  • 中继侧:calls.jsonl 每次请求一条记录,键为 vec-relay-<upstream>/<model>。
  • 合并口径(by_model / by_day / 成本):经中继的调用只算中继侧(中继还能看到 run 之外的调用:报告、审计),其余调用算 run 侧,不重复计数。
  • 对账:每个 run、每个经中继的模型,比较 run 侧 total 和带该 run id 的中继侧 total,差额在 2% 以内记 match;同时比较 opencode 的 steps 数和中继请求数。
  • 口径注意:run 侧的 total 把 reasoning 另外加上(opencode 的 step_finish 把 reasoning 和 output 分开记),中继侧 output 已经包含 reasoning。第一次实跑如果对不上,改 RUN_REASONING_SEPARATE。
  • 成本(agent/configs/pricing.yaml,数字由用户填):
    • 包月(Token Plan、Claude 订阅):当月单价 = 月费 ÷ 当月合并口径的总 token;
    • 按量:token 数 × 每百万 token 单价;
    • 单价为空时成本记 null,并标 cost_incomplete,不做猜测。
  • 日期按北京时间(--tz-offset-hours 8)。

8. 待验证(首次实跑)

  • opencode 1.18.32 是否内置 @ai-sdk/anthropic(沙箱里不能装 npm 包);provider 的 options.headers 是否会透传到请求上。
  • Bun 的 fetch 对 http:// 目标是否走 HTTP_PROXY(应当走;如果它对 http 也用 CONNECT,egress 别名也支持)。
  • run 侧和中继侧的 reasoning 口径(§7)。
  • claude -p 在 VM 的 systemd 用户服务环境下能否读到登录态(PATH 已包含 ~/.local/bin;登录态在 ~/.claude)。

9. 文件

文件内容
agent/relay/server.py中继(两种 Claude 后端 + OpenAI 兼容上游 + 调用记录)
agent/relay/systemd/vec-relay.service、vec-relay-tunnel.servicesystemd 用户单元模板
agent/relay/install.shVM 安装(令牌、单元、启动)
agent/relay/spark_setup.shSpark 侧 opencode provider 与 auth(默认 dry run)
agent/relay/relay_test.py假上游、假 claude CLI、egress 别名的端到端测试
agent/scoring/egress_proxy.py新增别名 name=host:port(明文 HTTP 转发到 loopback)
agent/search/model_fallback.py、agent/search/tests/test_model_fallback.py回退链
agent/harness.pylock_fields:可选的 models_fallback
agent/records/llm_usage.py、agent/records/llm_usage_test.py用量对账
agent/configs/pricing.yaml价目(数字留空)