← 工作原理 · 原文件 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/messages | VM 上 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 千问],中继回 503relay_mode_disabled,回退模块把它当额度类错误立即跳过(§4),所以 draft 一直用千问,直到 key 到位、探测成功。
3. 中继(agent/relay/server.py)
- Python 标准库(
ThreadingHTTPServer+http.client),VM 的/usr/bin/python3,不装额外的包。 - 鉴权:客户端在
x-api-key或Authorization: Bearer里带中继令牌(gopassservices/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;控制器直连得到 ECONNREFUSED | PROVIDER_RE 能匹配 502 / ECONNREFUSED。连续 N 次后该中继模型被跳过,draft 退回 Token Plan 千问,单轮角色退回千问;隧道恢复后经 half-open 探测切回。连接失败是秒级的,代价是 N 次快速失败的调用 |
| 没有 Anthropic API key | 503 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_missing | key 读到一次后就缓存在进程里;重启中继前确认 gopass 能读 |
| 中继令牌错 | 401 relay_auth | 配置错误,不应出现;会按提供方错误计数,最后切走 |
安全:沙箱里的 agent 能读到 per-call 的 auth.json,也就能拿到中继令牌,经 egress 别名自己调用中继。这和 Token Plan key 现在的暴露面一样,而且每次调用都会记进 calls.jsonl。不管怎样,真实的 Anthropic / 百炼 key 都不会离开 VM。
6. 接线点清单(未做;controller.py 由另一个 agent 改)
controller.load_search_config(约 177–199 行):在 models 被重建之前,用原始的models:段算出model_fallback.normalize(raw_models, cfg.get("ops"))。ValueError转成SearchError。cfg["models"]仍然只放主模型,这样不用回退的配置search_hashes["models"]不变。make_search_lock(约 537 行):fb = model_fallback.lock_entry(raw_models, ops);if fb: body["models_fallback"] = fb。harness 已经支持这个字段。另一种做法是把models_fallback放进 cfg,由config字段一起锁住,但我们没选这个。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。
- 初始化:
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按实际模型取。
- 接受本次调用的模型覆盖(例如
- 凭据镜像:
harness.CredentialMirror.prepare现在只复制一个 provider(cfg["model"]);- 改成复制
model_fallback.providers(spec)里的全部 provider(roles._state会照搬 mirror 里的 auth.json); vec-relay-*的条目放的是中继令牌。
- allowed_hosts 校验:链里有
vec-relay-*provider 时,sandbox.allowed_hosts里必须有vec-relay.local=127.0.0.1:18790,否则SearchError。 ops.probe_models(约 874 行):- 链里的每个模型都探测一次,记进
provider_models; vec-relay-*不在 models.dev 目录里,改为用中继令牌 GEThttp://127.0.0.1:18790/healthz,看modes和上游 key 在不在;- 也可以起一个后台线程,每 60 s 探一次
/healthz,连不上就对该中继的所有模型record(…, "relay unreachable: ECONNREFUSED")。这样能省掉 §5 第一行那 N 次失败的调用。
- 链里的每个模型都探测一次,记进
- 单轮角色走订阅模式:
- 在
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。
- 在
- tune(
tune.py约 670 行)与report/write.llm_call:用同一个select/record包一层。报告写手、G44 / G50 走 6.8 的订阅路径。 - 看板 / 对账:
- 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。
- calls.jsonl 在 VM 上,Spark 上的看板要读它,得先同步过去,例如 VM 上加一个 timer:
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.service | systemd 用户单元模板 |
agent/relay/install.sh | VM 安装(令牌、单元、启动) |
agent/relay/spark_setup.sh | Spark 侧 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.py | lock_fields:可选的 models_fallback |
agent/records/llm_usage.py、agent/records/llm_usage_test.py | 用量对账 |
agent/configs/pricing.yaml | 价目(数字留空) |