1. 从一次长程任务翻车说起:Agent Harness Runtime 到底解决什么问题
如果你正在做 Agent 工程,大概率遇到过这种场景:让 Agent 跑一个"重构模块 + 补测试 + 更新文档"的任务,前 20 分钟一切正常,到第 40 分钟它突然宣布"任务已完成",但你一看仓库,测试没跑、文档没改、还有个半截的 TODO 留在代码里。你翻日志,发现它其实在第 3 轮就把"当前阶段完成"误判成了"整个任务完成"。
这不是模型不够聪明,而是Agent Harness Runtime没搭好。Harness Runtime 可以理解成"模型之外的执行外壳"——模型只负责推理下一步,Harness 负责把这一步变成可执行、可观测、可纠偏的动作。它至少包含三层职责:工具循环(把推理结果接到真实工具)、状态外置(把中间状态写到模型外面)、长程任务调度(跨轮次编排任务并判定完成)。
适合谁看这篇:正在用 Claude Code、Codex CLI、Cline 这类工具做真实项目,或者自己在写 Agent 框架、想让 Agent 稳定跑 30 分钟以上任务的工程师。我会按"工具循环 → 状态外置 → 长程调度"三条主线拆,每一条都给出可复制的配置片段和验证步骤,你可以直接在自己的工程里复现。
先说结论:换模型改变能力上限,改 Harness 改变模型能把上限发挥到什么程度。下面进入具体拆解。
2. TaoToken 前置准备:给 Harness Runtime 接一个稳定的模型入口
在拆 Harness 之前,得先解决一个前置问题:Harness Runtime 里的模型调用入口。工具循环每转一圈都要打一次模型,长程任务动辄几十上百次调用,如果入口不稳定,整个 Harness 的观测数据全是噪音。
我这边做 Agent 工程时,模型入口统一走 TaoToken。它的定位是给 Agent 工程提供一个兼容 OpenAI / Anthropic 协议的调用入口,好处是 Harness 里不用为不同模型写多套适配层。官网在 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 端点是 https://taotoken.net/api (这个不加 UTM,直接配到代码里)。
你需要准备三件套,缺一不可:
- Base URL:
https://taotoken.net/api - API Key:在控制台 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite 里创建,形如
sk-... - Model ID:比如
claude-sonnet-4-5、gpt-4o这类,具体以模型对话页 https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite 里列出的为准
如果你用的是 Claude Code 这类 CLI,它读的是环境变量;如果你用的是自己写的 Harness,那就把这三个值塞进配置。下面给一份通用的.env片段,Harness 启动时加载:
# .env —— Harness Runtime 模型入口配置 TAOTOKEN_BASE_URL=https://taotoken.net/api TAOTOKEN_API_KEY=sk-你的key TAOTOKEN_MODEL=claude-sonnet-4-5 # 长程任务相关:单次请求超时和最大重试 HARNESS_REQUEST_TIMEOUT=120 HARNESS_MAX_RETRY=3注意一点:Harness 里的模型调用要单独包一层 client,把超时、重试、token 统计都收口,别让工具循环里散落着裸fetch。这样后面做 Trace 和 Cost 统计时才有统一数据源。API Key 的创建入口在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite ,接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite ,遇到协议细节可以对照。
前置准备就这些,接下来进入 Harness 本体。
3. 工具循环与状态外置:可复制的 Harness Runtime 配置片段
工具循环的本质是一个 while 循环:模型输出 → 解析工具调用 → 执行工具 → 把结果塞回上下文 → 再问模型。听起来简单,但工程上最容易翻车的是两件事:工具结果无脑塞进 Context和中间状态只存在内存里。
先看工具循环的骨架。下面是一段 Python 伪代码,重点看offload和state两个处理:
# harness/loop.py —— 工具循环骨架 import json, os, hashlib from pathlib import Path STATE_DIR = Path(".harness/state") STATE_DIR.mkdir(parents=True, exist_ok=True) def offload_tool_result(tool_name: str, raw: str, max_inline: int = 2000) -> str: """工具输出超过阈值就落盘,Context 里只留摘要和路径""" if len(raw) <= max_inline: return raw digest = hashlib.md5(raw.encode()).hexdigest()[:8] path = STATE_DIR / f"tool_{tool_name}_{digest}.log" path.write_text(raw, encoding="utf-8") return f"[offloaded] {len(raw)} chars -> {path}\nhead:\n{raw[:500]}" def run_loop(client, tools, goal: str, max_turns: int = 50): messages = [{"role": "user", "content": goal}] for turn in range(max_turns): resp = client.chat(model=os.environ["TAOTOKEN_MODEL"], messages=messages, tools=tools) msg = resp.choices[0].message messages.append(msg) if not msg.tool_calls: # 没有工具调用,检查是否真的完成 if is_task_done(messages): return messages messages.append({"role": "user", "content": "继续,检查 plan 文件里未完成项"}) continue for call in msg.tool_calls: raw = execute_tool(call.function.name, json.loads(call.function.arguments)) messages.append({ "role": "tool", "tool_call_id": call.id, "content": offload_tool_result(call.function.name, raw), }) persist_state(messages) # 每轮结束落盘 return messages这段代码里有两个关键设计。第一,offload_tool_result把超过 2000 字符的工具输出写到.harness/state/下,Context 里只保留路径和头部 500 字符。这样即使跑 50 轮,Context 也不会被日志撑爆。第二,persist_state每轮结束把 messages 落盘,会话重启后可以从磁盘恢复。
状态外置分三层,职责别混:
| 层级 | 存什么 | 典型实现 |
|---|---|---|
| Filesystem | plan 文件、工具输出快照、handoff 摘要 | .harness/state/*.log、plan.md |
| Git | 代码探索、分支回滚、审计 | 独立 worktree + 分支 |
| Memory Store | 跨会话事实、客户偏好、历史修复路径 | KV / 向量库 / 结构化 Markdown |
再往外还有 Search 和 MCP,处理训练截止日之后的信息。原则就一条:凡是不应该随 Context 被截断而消失的信息,都不要只放在 Context 里。
如果你用 Claude Code,它的 Hook 配置可以直接把"改完代码跑类型检查"变成确定性纪律。下面是一份settings.json片段,路径和字段名保持原样:
{ "hooks": { "PostToolUse": [ { "matcher": "Edit|Write|MultiEdit", "hooks": [ { "type": "command", "command": "cd $CLAUDE_PROJECT_DIR && pnpm tsc --noEmit 2>&1 | head -50" } ] } ], "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "python3 .harness/guard.py --cmd \"$TOOL_INPUT\"" } ] } ] } }Hook 的设计精神是"成功静默,失败喧哗":typecheck 通过时不返回任何信息,避免污染 Context;失败时把错误塞回下一轮,模型必须修。这样你就不用在CLAUDE.md里反复写"记得跑类型检查"——纪律已经从提示词变成运行时逻辑。
4. 长程任务调度验证:一次跨轮次任务的完整观测步骤
配置写完了,得验证它真的能跑长程任务。下面这套步骤我实测过,你可以照着复现,重点观测三件事:状态是否落盘、退出条件是否生效、完成判定是否独立。
第一步:准备一个会"骗人"的任务。故意给一个需要多轮才能完成的目标,比如"给src/utils/下所有函数补 JSDoc,并跑通pnpm test"。这种任务模型很容易在第 2 轮就说"已完成"。
第二步:写 plan 文件。在项目根目录建.harness/plan.md:
# Task Plan - [ ] 扫描 src/utils/ 下所有导出函数 - [ ] 为每个函数补 JSDoc - [ ] 运行 pnpm test 并确认通过 - [ ] 更新 CHANGELOG.md第三步:启动 Harness 并观察状态目录。跑起来后,另开一个终端:
watch -n 2 'ls -la .harness/state/ && echo "---" && tail -5 .harness/plan.md'你应该看到.harness/state/下不断出现tool_*.log文件,plan 文件的勾选状态在推进。如果跑了 10 轮 plan 文件还是全空,说明状态外置没生效,回去检查persist_state是不是被异常吞了。
第四步:验证退出条件。在 Stop Hook 里加一段检查,模型宣布完成时先核对 plan:
# .harness/stop_check.sh #!/bin/bash UNCHECKED=$(grep -c '^- \[ \]' .harness/plan.md) if [ "$UNCHECKED" -gt 0 ]; then echo "还有 $UNCHECKED 项未完成,继续执行" exit 1 fi exit 0Stop Hook 返回非零时,Harness 会把这段输出塞回下一轮,模型被迫继续。这就是"防止过早宣布胜利"的兜底。
第五步:独立 Evaluator 验收。别让写代码的 Agent 自己说"好了"。开工前定义 sprint contract:哪些测试必须通过、覆盖哪些边界。最后由独立 Judge 对照合同验收。你可以用同一个模型入口起一个独立会话,prompt 里只给合同和产物路径,不给生成过程。
跑完这一套,你会拿到一份完整的 Trace:每轮的工具调用、落盘路径、plan 推进、退出拦截记录。这份数据才是 Harness 迭代的依据。
5. 常见报错排查:401、local proxy failed、reading choices、OAuth
Harness 跑起来后,报错基本集中在模型入口和工具执行两块。下面按真实报错对照排查。
401 Unauthorized。最常见,八成是 API Key 没加载。检查顺序:.env是否被 Harness 读取(有些框架不自动加载)、Key 是否有多余空格、Base URL 是否写成了https://taotoken.net/api/(末尾斜杠有时会导致路径拼接错误)。用 curl 单独验证一次:
curl -s https://taotoken.net/api/v1/chat/completions \ -H "Authorization: Bearer $TAOTOKEN_API_KEY" \ -H "Content-Type: application/json" \ -d '{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":"ping"}]}'返回 200 说明入口没问题,报错在 Harness 层。
local proxy failed。这个报错通常出现在 CLI 类工具里,意思是本地代理进程没起来或端口被占。检查~/.claude/settings.json或对应工具的配置,确认 Base URL 指向的是https://taotoken.net/api而不是localhost:xxxx。如果你之前配过本地转发,把那段删掉,直接用远端入口。
reading choices 报错。典型信息是Cannot read properties of undefined (reading 'choices')。这说明响应体结构和代码预期不符——要么是请求根本没成功(返回了错误 JSON),要么是模型名写错了。先打印原始响应:
resp = client.chat(...) print(resp.model_dump_json(indent=2)) # 看真实结构如果resp里是{"error": {...}},那就是入口问题;如果有choices但代码取不到,检查是不是用了 OpenAI 格式去解析 Anthropic 格式的响应。
OAuth 相关报错。Claude Code 这类工具首次启动会走 OAuth 流程,如果你已经配了 API Key,就不需要再走 OAuth。检查~/.claude.json里是否有残留的oauthAccount字段,有的话清掉,改用环境变量方式。三件套(Base URL + Key + Model ID)配全之后,OAuth 报错基本消失。
排查时记住一个原则:先用 curl 验证入口,再查 Harness 代码。入口通了,问题一定在工具循环或状态层。
6. 把 Harness 当成会进化的工程系统
回到开头那个翻车场景。现在你有了工具循环的 offload 机制、三层状态外置、Stop Hook 的退出拦截、独立 Evaluator 的验收,那个"第 40 分钟宣布完成"的问题基本不会再出现。但 Harness 不是一次配好就完事的东西。
我踩过的坑是:早期往CLAUDE.md里堆了几十条规则,结果模型该忘还是忘。后来改成 Ratchet 机制——每条规则必须能追溯到一次具体失败,事故没发生就不加规则;模型变强后旧规则要删。Harness 不是配置垃圾抽屉,只增不减会变成噪音。
如果你想让 Agent 长期跑编码任务,可以看看 Coding Plan https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite ,它把长程任务的调用配额和调度做了收口。想先验证模型行为,去模型对话页 https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite 手动跑几轮,观察工具调用和状态落盘,再决定怎么改 Harness。
最后留一个可操作的建议:今晚就给你的 Harness 加一个 Stop Hook,检查 plan 文件里未完成项。这一个改动,就能挡掉大部分"过早宣布胜利"的翻车。