1. 子代理跑到第 3 轮 401:问题不在模型,在出口
上一轮子代理编排实验里,我碰到过一个很典型的故障:父代理(orchestrator)一路正常,负责检索的 scout、负责改代码的 coder、负责跑测试的 tester 三个子代理,从第三轮开始整齐地返回401 Unauthorized。父代理的会话还在跑,日志里也有正常的工具调用记录,唯独子代理的请求全部被打回。第一反应是模型侧的问题,换了模型、重启了终端、甚至把 prompt 精简了一半,401 依然稳定复现。
真正的根因很朴素:父代理读的是我新写入 shell 的 Key,而子代理是通过 Job Panel 拉起的独立进程,继承的是另一份旧的环境变量;更麻烦的是,三个子代理共用一把 Key,出了事只能看到总量,看不到是谁在烧。顺着这个坑,我把整条链路的出口收敛到了 TaoToken(https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_content=codex_subagent_401_intro)。它不生产模型,解决的是「给 Codex、Claude Code 这类工具统一发 Key、统一 Base URL、统一计量」这一层的事——恰好是子代理编排里最容易被忽略、又最先出问题的那一层。
最近 DeepSeek Harness 的几个 rc 版本连续更新,把 Codex 与 Claude Code 子代理接进了 Job Panel,支持作为 Profile Bundle 按需安装,也支持非交互权限模式和多个命名实例;插件还能自行注册设置卡片。编排能力确实灵活了很多,但灵活带来的副作用是:子代理不再是一个「藏着的前台」,而是一堆能被点名、能并行的独立任务单元。既然它们各自是独立单元,凭什么共用一把 Key?本文就按我踩坑的顺序,把「任务图 → Key 分配表 → Codex config.toml → Claude Code settings.json → 报错对照」这条链路完整写一遍,配置可以直接抄。
2. 先画任务图,再决定发几把 Key
很多人一开始就想写配置,结果 Key 越配越多、越配越乱。正确的顺序是先画任务图,确认哪些节点真的会发请求,再决定发几把 Key。我用的是纯文本的任务图,不用画图工具,直接写进仓库的AGENTS.md或项目 README 里,方便子代理自己读到:
orchestrator (父代理, 长会话, 读写) ├── scout-01 只读检索:读文件、grep、查资料,产出摘要 ├── scout-02 只读检索:并行处理第二个子问题 ├── coder-01 写代码:在 workspace 内落改动 │ └── tester-01 只读 + 执行测试命令,不写源码 └── reviewer-01 只读 diff,输出评审意见,不改文件这张图定下来,Key 的粒度基本就定了。我的原则是三条:
第一,父代理和子代理用不同的 Key。父代理是长会话,token 消耗曲线是持续爬升的;子代理是短任务,消耗是脉冲式的。混在一把 Key 里,你看不出是长会话在拖还是在被某个子代理刷。
第二,权限不同的子代理用不同的 Key。scout 和 reviewer 只需要读,coder 需要写,tester 需要执行命令。Key 是唯一能在网关侧区分「谁在发请求」的标记,如果不分开,事后你没有任何办法审计「是哪一类任务在消耗」。
第三,可命名实例 = 可命名 Key。Job Panel 支持多个命名实例之后,实例名天然就是一个稳定的 Key 别名,比如codex-scout-01。这个对应关系一定要写成表,别只存在脑子里。
把 Key 想成「给子代理发的工牌」,而不是「给项目配的密码」。工牌可以随时回收、可以限定工位、可以单独查考勤;密码丢了一次就是全军覆没。
3. Codex 侧接入:config.toml 的 provider / profile / env_key 三段式
Codex 的配置入口是~/.codex/config.toml。要让 Codex 走 TaoToken,核心只有三步:声明一个model_provider指向 TaoToken 的 Base URL、用env_key指定从哪个环境变量读 Key、用profile把不同子代理分到不同的 provider 上。
先看基础版本,这是单子代理最小可运行配置:
# ~/.codex/config.toml model_provider = "taotoken" model = "gpt-5-codex" approval_policy = "on-request" sandbox_mode = "workspace-write" [model_providers.taotoken] name = "TaoToken" base_url = "https://taotoken.net/api" env_key = "TAOTOKEN_API_KEY" wire_api = "responses"几个字段的注意点:
base_url统一写https://taotoken.net/api,不要在尾部再手写多余的/v1、/chat/completions之类的后缀。路径拼接交给工具侧处理,重复拼接是 404 的高频来源。env_key写的是环境变量的名字,不是 Key 本身。这意味着 Key 永远不进配置文件、不进 git,这比写死在 toml 里安全得多。wire_api按工具实际支持情况选择,配置对不上时最先表现出的就是请求体不兼容,而不是 401。
对应的环境变量:
export TAOTOKEN_API_KEY="YOUR_API_KEY"然后跑一个最小验证,确认父代理这条线通了再往下走:
codex --profile default "用一句话说明这个仓库的入口文件是哪个"接下来是关键的一步:让不同子代理用不同的 Key。Codex 的env_key是挂在 provider 级别的,所以最干净的做法是注册多个 provider 块,每个块指向不同的环境变量,再用 profile 把它们分给不同子代理:
# ~/.codex/config.toml —— 多子代理版本 # 父代理 provider [model_providers.tt_orchestrator] name = "TaoToken" base_url = "https://taotoken.net/api" env_key = "TAOTOKEN_KEY_ORCH" wire_api = "responses" # 只读检索子代理 provider [model_providers.tt_scout] name = "TaoToken" base_url = "https://taotoken.net/api" env_key = "TAOTOKEN_KEY_SCOUT" wire_api = "responses" # 写代码子代理 provider [model_providers.tt_coder] name = "TaoToken" base_url = "https://taotoken.net/api" env_key = "TAOTOKEN_KEY_CODER" wire_api = "responses" # 测试子代理 provider [model_providers.tt_tester] name = "TaoToken" base_url = "https://taotoken.net/api" env_key = "TAOTOKEN_KEY_TESTER" wire_api = "responses" [profiles.orchestrator] model_provider = "tt_orchestrator" model = "gpt-5-codex" approval_policy = "on-request" [profiles.scout] model_provider = "tt_scout" model = "gpt-5-codex" approval_policy = "never" # 只读检索,非交互执行 sandbox_mode = "read-only" [profiles.coder] model_provider = "tt_coder" model = "gpt-5-codex" approval_policy = "on-failure" sandbox_mode = "workspace-write" [profiles.tester] model_provider = "tt_tester" model = "gpt-5-codex" approval_policy = "never" sandbox_mode = "workspace-write"注意approval_policy = "never"只适合那种确定不会碰危险命令的子代理。非交互权限模式很爽,但它意味着没有人在中间拦一下,所以务必配sandbox_mode = "read-only"做兜底。这是 Harness 侧强调「非交互权限模式 + 多个命名实例」之后,我认为最需要补的一条实践。
配置写完了,怎么保证每个子代理真的拿到自己那把 Key?答案是启动脚本,别指望手动export。给子代理做一个 wrapper:
#!/usr/bin/env bash # scripts/run-subagent.sh set -euo pipefail SUBAGENT="${1:?usage: run-subagent.sh <scout|coder|tester|orchestrator>}" shift case "$SUBAGENT" in orchestrator) : "${TAOTOKEN_KEY_ORCH:?TAOTOKEN_KEY_ORCH is not set}" PROFILE="orchestrator" ;; scout) : "${TAOTOKEN_KEY_SCOUT:?TAOTOKEN_KEY_SCOUT is not set}" PROFILE="scout" ;; coder) : "${TAOTOKEN_KEY_CODER:?TAOTOKEN_KEY_CODER is not set}" PROFILE="coder" ;; tester) : "${TAOTOKEN_KEY_TESTER:?TAOTOKEN_KEY_TESTER is not set}" PROFILE="tester" ;; *) echo "unknown subagent: $SUBAGENT" >&2 exit 2 ;; esac exec codex --profile "$PROFILE" "$@"这个脚本做了一件很关键的事:如果对应环境变量没注入,直接 fail fast,而不是带着空 Key 去发请求。我前面那个 401 的坑,如果当时有这个:?检查,会在子代理启动的瞬间就报错退出,而不是跑三轮之后才暴露。
4. Claude Code 侧接入:settings.json、环境变量与 CC Switch 三件套
Claude Code 走的是另一套约定,配置入口是settings.json,用的是ANTHROPIC_*前缀。这里必须先说一句最容易犯的错:ANTHROPIC_*是 Claude Code 的字段,不能套到 Codex 上;Codex 用的是config.toml加自定义环境变量名。两套前缀不要串用,串用的典型症状就是「配置看起来没错,但请求永远打不到你想要的出口」。
Claude Code 的settings.json示例:
{ "env": { "ANTHROPIC_BASE_URL": "https://taotoken.net/api", "ANTHROPIC_AUTH_TOKEN": "YOUR_API_KEY", "ANTHROPIC_MODEL": "claude-sonnet-4-5", "ANTHROPIC_SMALL_FAST_MODEL": "claude-haiku-4-5" }, "permissions": { "allow": [ "Read", "Glob", "Grep" ], "deny": [ "Bash(rm:*)", "Bash(git push:*)" ] } }如果你习惯用环境变量而不是配置文件,等价写法是:
export ANTHROPIC_BASE_URL="https://taotoken.net/api" export ANTHROPIC_AUTH_TOKEN="YOUR_API_KEY"这里的ANTHROPIC_AUTH_TOKEN填的是你在 TaoToken 控制台创建的 Key,也就是本文一直用的YOUR_API_KEY占位。Key 的创建入口在控制台的 API Keys 页面,创建时建议直接用子代理命名,比如cc-reviewer-01,和前面的 Key 分配表对齐。
Claude Code 的子代理还有一个和 Codex 不同的地方:子代理默认继承父会话的进程环境。也就是说,如果你在同一个 shell 里启动 Claude Code,父代理和子代理会共用你export的那一个ANTHROPIC_AUTH_TOKEN。想让不同子代理走不同 Key,就得像 Codex 那样用一个 wrapper 在启动前注入,或者用claude的配置文件分层:
#!/usr/bin/env bash # scripts/run-cc-subagent.sh set -euo pipefail ROLE="${1:?usage: run-cc-subagent.sh <reviewer|tester>}" shift case "$ROLE" in reviewer) export ANTHROPIC_AUTH_TOKEN="${CC_KEY_REVIEWER:?CC_KEY_REVIEWER is not set}" ;; tester) export ANTHROPIC_AUTH_TOKEN="${CC_KEY_TESTER:?CC_KEY_TESTER is not set}" ;; *) echo "unknown role: $ROLE" >&2 ; exit 2 ;; esac export ANTHROPIC_BASE_URL="https://taotoken.net/api" exec claude "$@"至于CC Switch 三件套,它是很多人在多端之间切换时用的辅助工具,界面上通常分成三个 tab:Claude Code、Codex、Gemini CLI。每个 tab 里你只需要盯住三样东西:
| 端 | Base URL | Key 字段 | 备注 |
|---|---|---|---|
| Claude Code | https://taotoken.net/api | ANTHROPIC_AUTH_TOKEN | 配在settings.json的env里,或同名环境变量 |
| Codex | https://taotoken.net/api | env_key指向的自定义变量 | 配在~/.codex/config.toml的model_providers里 |
| Gemini CLI | https://taotoken.net/api | 对应 CLI 的 API Key 变量 | 字段名以该 CLI 官方文档为准 |
三件套的用法就是「三端同源、一 Key 一角色」:Base URL 三端保持一致,Key 按角色分开,切换 tab 不要把字段名互相搬。我见过最常见的错误是在 CC Switch 里把ANTHROPIC_AUTH_TOKEN复制到 Codex tab 的env_key里——Codex 那边的env_key要填的是变量名,不是 Key 值,填错就等着看 401。
5. Key 分配表:把 Token 账单按子代理切开
讲完配置,来交付本文承诺的第一个产物:Key 分配表。这张表建议直接放进仓库的docs/key-allocation.md,每次加子代理都更新一行。
| Key 别名 | 绑定子代理 | 权限范围 | 建议模型档位 | 限额策略 | 轮换周期 |
|---|---|---|---|---|---|
tt-orch-main | orchestrator | 读写 + 汇总 | 强推理档 | 每日上限最高 | 30 天 |
tt-scout-01 | scout-01 / scout-02 | 只读检索 | 轻量快档 | 按次任务限额 | 14 天 |
tt-coder-01 | coder-01 | workspace 写 | 代码档 | 按变更量估算 | 14 天 |
tt-tester-01 | tester-01 | 只读 + 执行测试 | 轻量快档 | 严格上限 | 7 天 |
tt-review-01 | reviewer-01 | 只读 diff | 强推理档 | 中等上限 | 14 天 |
这张表的价值不在于「好看」,而在于它让下面三件事变成可执行的:
第一,故障定位从「猜」变成「查」。当网关侧某个 Key 的请求量突然飙升,你能立刻定位到是哪个子代理,而不是在父代理的长会话日志里翻。
第二,权限回收从「全停」变成「只停一个」。某个子代理行为异常时,你只需要在控制台禁用对应那把 Key,父代理和其他子代理完全不受影响。这在非交互权限模式下尤其重要——子代理没人盯着,回收能力就是你的刹车。
第三,轮换不再是一个大工程。一个子代理一把 Key,意味着轮换可以按周期、按角色分批做。父代理那把 Key 轮换时,只需要重启父会话;子代理是短任务,下次启动自然拿到新的。
配套的任务图也要写清楚「谁可以把结果交给谁」。我在AGENTS.md里会写这么一段给子代理读:
编排约定: 1. 父代理负责拆解与汇总,不直接修改源码。 2. scout-* 只能返回结构化摘要,禁止回传完整文件内容。 3. coder-01 只能修改 workspace 内文件,禁止执行 git push。 4. tester-01 只能在只读模式下运行测试命令,失败输出需截断到 50 行内。 5. reviewer-01 只读 diff,输出必须包含「风险点 / 建议 / 是否阻塞」三栏。第 2 条和第 4 条的截断约束,是控制上下文成本的关键。子代理回传全量日志,是长会话成本失控最常见的原因,而且它和 Key 无关,换什么供应商都一样烧。
6. 报错对照与排查顺序:从 401 到上下文超限
子代理编排的报错大多长得不像「配置问题」,所以给一张对照表能省很多时间。排查顺序建议固定为:Key 是否注入 → Base URL 是否一致 → 模型标识是否存在于你的账号 → 权限策略 → 上下文长度。
| 现象 | 大概率原因 | 处理动作 |
|---|---|---|
| 父代理正常,子代理统一 401 | 子代理进程未继承 Key 环境变量 | 用 wrapper 注入,并在脚本里加:?强制校验 |
| 单个子代理 401,其他正常 | 该子代理绑定的 Key 被禁用或轮换过期 | 到控制台 API Keys 页面确认状态并重建 |
| 404 / 路径不存在 | base_url后手写了多余路径后缀 | 统一使用https://taotoken.net/api,去掉手工拼接 |
| 请求体不兼容、字段报错 | wire_api或协议字段与工具不匹配 | 按工具实际支持情况调整,别照抄别端的字段 |
| 429 | 多子代理共用一把 Key 触发限流 | 拆分 Key,按子代理独立计量 |
| 上下文超限 | 子代理回传全量日志或整文件 | 强制结构化摘要 + 输出截断 |
| 沙箱拒绝写入 | approval_policy与sandbox_mode冲突 | 只读角色保持 read-only,写入角色才放开 |
| 长会话中途开始失败 | 会话上下文累积过大 | 拆分任务、定期开新会话,别把长会话当状态存储 |
这张表里,我个人踩得最多的是第一行和第二行。它们的共同点是:配置文件看起来完全正确。因为问题从来不在配置文件里,而在「哪个进程、读了哪份环境」。这也是我坚持用 wrapper 脚本而不是手动export的根本原因——手动操作不可复现,脚本可以。
如果你在确认 Base URL 和模型标识时不确定,最省事的办法是先去模型对话页面手工发一条请求,确认路径通了,再回头调工具配置。手工能通、工具不能通,问题一定在工具的配置字段上,而不在出口本身。
7. 多模态与长会话:Job Panel 时代的新增 Token 变量
最近这波 Harness 更新里,对成本影响最大的其实不是子代理协作,而是多模态。模型适配器新增了多模态模型选项,支持配置原生图片请求;/goal、/plan这类命令可以接收图文输入,@菜单能引用文件和会话;MCP/ACP 侧还支持图片附件持久化,嵌套图片也能被转发。
这对编排的影响是具体的:
图片附件持久化意味着「每轮都带上」。一次性的图片输入成本可控,但一旦被持久化进会话,后续每一轮请求都会把它带上。长会话 + 图片持久化,是成本曲线最陡的组合。
嵌套图片转发意味着「一张图会走多个子代理」。父代理收到图,转发给 scout,scout 再传给下一个节点——每一跳都是一次完整的图片上下文。如果不加控制,同一个视觉输入会被计费多次。
我的做法是把视觉类任务单独拆出来,给它独立的 Key 和独立的模型档位,并在AGENTS.md里写明约束:
视觉任务约定: 1. 图片只在 vision-worker 子代理内处理,禁止在父代理长会话中直接持久化。 2. vision-worker 输出必须是文本结构化摘要,禁止把原图继续向下转发。 3. 图片输入统一先压缩到必要分辨率,再提交请求。 4. vision-worker 使用独立 Key(tt-vision-01),单独观察用量。对应到配置上,就是再补一个 provider 和一个 profile:
[model_providers.tt_vision] name = "TaoToken" base_url = "https://taotoken.net/api" env_key = "TAOTOKEN_KEY_VISION" wire_api = "responses" [profiles.vision-worker] model_provider = "tt_vision" model = "gpt-5-codex" approval_policy = "never" sandbox_mode = "read-only"另外,Job Panel 支持 Profile Bundle 按需安装之后,很自然的用法是:把「只读检索」「写代码」「跑测试」「视觉处理」做成四套 profile bundle,按任务类型动态挂载。这样子代理启动时拿到的不只是不同的 Key,还有不同的权限面和不同的模型档位。但要注意,bundle 装得越多、实例建得越多,Key 就越需要那张分配表来兜底——否则一个月后你会面对十几个没人知道用途的 Key。
至于长会话本身,稳定性更新里修了一批问题,但架构上的约束不会变:长会话适合「持续对话」,不适合「持续累积状态」。我的习惯是把状态写到文件里,让子代理每次启动时重新读,而不是指望父代理的上下文记住一切。这样既能控制上下文长度,也能让子代理的输入是可复现的。
8. 固化模板:让下一个子代理五分钟上线
最后把整条链路收成一个可执行清单,新加一个子代理时按顺序走:
- 在任务图里加一个节点,标明它是只读、写、执行还是视觉类。
- 在 Key 分配表里加一行,确定别名、权限范围、限额策略、轮换周期。
- 去 TaoToken 控制台创建这把 Key,命名与别名一致,创建完成后再继续下一步。
- 在
~/.codex/config.toml加一个model_providers块(base_url固定为https://taotoken.net/api,env_key指向新变量名)和对应的profile。 - 在 wrapper 脚本里加一个
case分支,带上:?校验,确保变量缺失时立即失败。 - 用最小 prompt 跑一次冒烟测试,确认这把 Key 单独可用,再接入编排。
- 如果是 Claude Code 侧的子代理,同样在
settings.json或 wrapper 里注入ANTHROPIC_BASE_URL与ANTHROPIC_AUTH_TOKEN,不要复用 Codex 的字段。
这套流程跑顺之后,加一个子代理的实际耗时大概五分钟:建 Key、加配置、加分支、冒烟。比起事后花两小时翻日志找「是谁把额度烧完了」,这个前置投入非常划算。
如果你还在评估阶段,可以先去模型对话页面手工发一条请求,确认模型和路径符合预期;再去看 Coding Plan 了解额度形态是否匹配你的子代理并发规模;确认之后到 API Keys 页面创建第一把 Key,命名就用tt-orch-main。Codex 的config.toml字段和 Claude Code 的settings.json字段对照,文档里有逐项说明,照着填基本不会踩路径拼接的坑。等父代理和第一个子代理都能稳定跑通,再把 Key 从一把拆成五把,那时候你拿到的就不只是一套能跑的配置,而是一张能看清每一次 Token 消耗去向的账单。