1. 任务悬挂与资源泄漏:自建 Harness 时最容易被忽略的终止设计
如果你正在用 Claude Code 搭一套自己的 Harness,大概率遇到过这种场景:Agent 明明已经把代码改完了,却还在反复调用read_file确认;或者某个工具调用失败后,模型陷入"重试—失败—再重试"的死循环,跑了二十分钟还在烧 token。更糟的是,进程被 Ctrl+C 中断后,临时目录没清、子进程没杀、session 状态没落盘,下次恢复时直接报状态冲突。
这些问题的根因,往往不是模型能力不够,而是 Harness 的**终止条件(Termination)和生命周期(Lifecycle)**没有设计好。Claude Code 在这块的实现思路很值得借鉴:它把"什么时候停"拆成六类独立触发点,把"停的时候做什么"交给生命周期 Hook 统一编排,再用状态机约束转换合法性。这样即使某一类终止条件漏判,其他几类也能兜底,不会出现任务悬挂。
这篇会从工程实现角度,把终止条件与生命周期的协作方式讲清楚。适合已经跑通 Claude Code 基础接入、想进一步做生产级 Harness 的同学。核心检索词先明确:Claude Code Harness Engineering 的终止条件与生命周期管理,本质是给 Agent 循环装上一套"刹车 + 熄火 + 收尾"的完整机制。下面从问题场景开始,一步步给出可复制的配置片段和验证方法。
我试过把终止逻辑全塞进一个while循环里判断,结果代码越写越乱,加一个条件就要动主循环。后来按 Claude Code 的分层思路重构,主循环只负责调度,终止判断和生命周期回调各自独立,维护成本立刻降下来。
2. TaoToken 前置:把 Claude Code 的模型调用链路先跑通
在动手改 Harness 之前,得先保证模型调用这条链路是通的。Claude Code 本身是客户端,它需要一个兼容 Anthropic 接口的服务端来承接请求。TaoToken 提供的就是这一层:官网 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 入口是 https://taotoken.net/api 。
接入方式很直接,Claude Code 通过环境变量读取 Base URL 和 Key。你需要先在控制台创建一个 API Key,地址是 https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite 。创建时注意权限范围,Harness 调试阶段建议单独建一个 Key,方便按项目统计用量,也避免和线上 Key 混用。
拿到 Key 之后,配置三个核心变量。Base URL 填https://taotoken.net/api,注意这里不加 UTM 参数,保持接口地址干净。Key 填刚才创建的那串。Model ID 按你实际要用的模型填,比如claude-sonnet-4-5这类标识。这三件套是后面所有配置的基础,缺一个都会在请求阶段报 401。
如果你还没验证过模型是否可用,可以先去模型对话页面发一条测试消息: https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite 。确认返回正常,再进入 Harness 的终止条件改造。这一步别跳过,否则后面排查终止问题时,你分不清是终止逻辑写错了,还是模型压根没调通。
对于要长期跑编码任务或 Agent 的场景,可以考虑 Coding Plan: https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite 。它的意义在于把额度管理和任务生命周期绑定,配合后面要讲的 token 预算终止条件,能更精确地控制单次任务的资源上限。
接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite ,里面有完整的参数说明和错误码对照。建议在写终止条件之前先过一遍错误码部分,因为很多"看起来像终止失败"的问题,其实是请求阶段就返回了错误,被 Harness 误判成了正常终止。
3. 可复制的终止条件配置与生命周期回调片段
这一节是重点,给出能直接落地的配置。Claude Code 的终止条件分六类:无 Tool Call、最大轮次、Token 预算、Tripwire、用户中断、安全拒答。生命周期状态则是 Created → Running → Completed/Stopped/Failed/Timeout/Aborted 这套流转。
先看终止条件的配置文件。我用 JSON 来组织,方便和 Harness 主逻辑解耦:
{ "termination": { "noToolCall": { "enabled": true, "requireTextBlock": true }, "maxTurns": { "default": 50, "simple": 5, "complex": 100 }, "tokenBudget": { "budgetTokens": 200000, "warnThreshold": 0.9, "emergencyCompact": true }, "tripwires": [ { "name": "cost_limit", "maxCostUsd": 5.0 }, { "name": "tool_call_limit", "maxCalls": 1000 }, { "name": "duration_limit", "maxDurationMs": 600000 } ], "userInterrupt": { "listenSIGINT": true, "listenEscape": true }, "safetyRefusal": { "enabled": true, "indicators": ["I cannot", "I'm not able to", "This request appears to violate"] } } }这份配置里,maxTurns分了三档,实际使用时按任务类型选。tokenBudget的emergencyCompact很关键,它对应 Claude Code 里预算耗尽时先尝试压缩历史、压缩后如果 token 降到预算 80% 以下就继续跑,否则才真正终止。这个设计能避免"差一点点就完成"的任务被硬砍。
生命周期回调用 Hook 注册,事件类型包括 start、stop、pause、resume、error。下面是一个可复制的 Hook 注册片段:
type LifecycleEvent = 'start' | 'stop' | 'pause' | 'resume' | 'error'; interface LifecycleContext { sessionId: string; messages: Message[]; reason?: string; state: AgentState; } const lifecycleHooks: Record<LifecycleEvent, Array<(ctx: LifecycleContext) => Promise<void>>> = { start: [], stop: [], pause: [], resume: [], error: [], }; function registerLifecycleHook( event: LifecycleEvent, handler: (ctx: LifecycleContext) => Promise<void> ): void { lifecycleHooks[event].push(handler); } async function emitLifecycleEvent( event: LifecycleEvent, ctx: LifecycleContext ): Promise<void> { for (const handler of lifecycleHooks[event]) { try { await handler(ctx); } catch (err) { logEvent('lifecycle_hook_error', { event, error: String(err) }); } } } registerLifecycleHook('stop', async (ctx) => { await saveSessionState(ctx.sessionId, ctx.messages); await cleanupResources(ctx.sessionId); logEvent('session_stopped', { sessionId: ctx.sessionId, reason: ctx.reason }); }); registerLifecycleHook('error', async (ctx) => { await saveSessionState(ctx.sessionId, ctx.messages); logEvent('session_failed', { sessionId: ctx.sessionId, reason: ctx.reason }); });状态机部分,用一张转换表约束合法性,防止出现"从 completed 又跳回 running"这种脏状态:
type AgentState = 'idle' | 'running' | 'paused' | 'stopping' | 'completed' | 'failed'; const STATE_TRANSITIONS: Record<AgentState, AgentState[]> = { idle: ['running'], running: ['paused', 'stopping', 'completed', 'failed'], paused: ['running', 'stopping'], stopping: ['completed', 'failed'], completed: [], failed: [], }; function canTransition(from: AgentState, to: AgentState): boolean { return STATE_TRANSITIONS[from].includes(to); }注意completed和failed是终态,不允许再转出。这一点在恢复 session 时特别重要,如果状态断言发现终态被改写,说明生命周期 Hook 里有逻辑写错了。
如果你用的是 Claude Code 的 settings 文件来管理配置,可以把终止条件写进settings.json,路径和字段名保持一致,这样 Harness 启动时直接读取,不用额外解析。Cline MCP 或 Codex 的auth.json场景下,Base URL、Key、Model ID 三件套同样要写全,否则终止条件还没触发,请求就先失败了。
4. 验证请求与成功结果:用日志和状态断言确认终止行为
配置写完不代表生效,必须验证。验证分两层:一是终止条件是否按预期触发,二是生命周期 Hook 是否在正确时机执行。
先看终止条件的验证。最直接的方式是构造一个必然触发某类终止的任务。比如验证最大轮次限制,可以让模型执行一个需要反复确认的任务,把maxTurns临时调到 3,观察是否在第 3 轮后停止并输出摘要。日志里应该出现max_turns_exceeded事件,且turnCount等于 3。
验证 Token 预算终止,把budgetTokens调到 5000,跑一个稍长的任务。预期行为是:接近预算时先出现 warning,达到预算后触发emergencyCompact,压缩后如果 token 降到 4000 以下则继续,否则终止并记录token_budget原因。日志里要能看到压缩前后的 token 数对比。
Tripwire 的验证更简单,把maxCostUsd设成 0.01,跑一个会多次调用工具的任务,观察是否在成本超限时立即停止。这里要注意,Tripwire 是硬中止,不走优雅退出流程,所以资源清理要靠error或stopHook 兜底。
生命周期 Hook 的验证,重点看stop事件是否在每次终止后都触发。可以在 Hook 里加一条日志,记录sessionId和reason。跑几次不同类型的终止,检查日志里stop事件的reason是否和实际终止原因一致。如果发现某类终止没有触发stop,说明该终止路径没有调用emitLifecycleEvent。
状态断言的写法,是在每次状态转换后校验:
function assertStateTransition(from: AgentState, to: AgentState): void { if (!canTransition(from, to)) { throw new Error(`Invalid state transition: ${from} -> ${to}`); } logEvent('state_transition', { from, to, timestamp: Date.now() }); }跑一轮完整任务,检查日志里的状态序列是否符合idle → running → stopping → completed或idle → running → failed。如果出现running → completed跳过了stopping,说明终止流程没有走完,可能漏了资源清理步骤。
成功的结果应该是:任务正常完成时,日志有no_tool_calls或completed事件,stopHook 执行了状态保存和资源清理,最终状态是completed。任务异常时,日志有对应的终止原因,errorHook 执行了清理,最终状态是failed。两种情况下都不应该出现进程残留或临时文件未删除。
5. 本篇常见错排查:401、local proxy failed、reading choices、OAuth
实际调试时,报错往往集中在几个固定位置。下面按真实报错逐个对照。
401 Unauthorized:最常见。先检查 Base URL 是不是https://taotoken.net/api,注意不要多加路径或斜杠。再检查 Key 是否复制完整,有没有多余空格。如果 Key 是在控制台新建的,确认权限范围包含你要用的模型。还有一种情况是环境变量没生效,Claude Code 读的是ANTHROPIC_BASE_URL和ANTHROPIC_API_KEY,检查这两个变量名是否写对。
local proxy failed:这个报错通常出现在 Harness 尝试通过本地代理转发请求时。先确认没有配置额外的代理层,Base URL 直连即可。如果 Harness 内部有代理逻辑,检查代理目标地址是否指向了正确的 API 入口。这个报错和终止条件无关,但会让人误以为是终止逻辑没触发,实际是请求根本没发出去。
reading choices 相关报错:这类报错一般出现在解析模型返回时。如果返回体里没有预期的choices字段,说明接口返回格式和 Harness 预期不一致。检查 Model ID 是否填对,以及请求的接口路径是否匹配。Claude Code 用的是 Anthropic 格式,不是 OpenAI 格式,字段名差异会导致解析失败。这种情况下终止条件不会触发,因为 Harness 卡在了解析阶段。
OAuth 相关报错:如果 Harness 里配置了 OAuth 流程,但实际用的是 API Key 认证,两者会冲突。检查配置文件里是否残留了 OAuth 的 token 刷新逻辑。用 API Key 接入时,应该走x-api-key请求头,而不是 Bearer token。OAuth 报错往往伴随 401,容易和 Key 错误混淆,排查时先看请求头里带的是哪种认证方式。
另外,如果用了 CC Switch 或 Cline MCP,配置里必须写全 Base URL、Key、Model ID 三件套。少任何一个,都会在请求阶段失败,表现和终止条件失效很像。排查顺序建议是:先确认请求能通,再确认终止条件触发,最后确认生命周期 Hook 执行。顺序反了会浪费很多时间。
6. 语义一致的 CTA:按场景选择接入入口
终止条件和生命周期调通之后,下一步通常是把它用到真实任务里。不同场景对应的入口不一样,按需选择。
如果你还在排障阶段,或者需要重新生成 Key、核对接入参数,走 API Keys 和接入文档: https://taotoken.net/console/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 。这两个页面能解决大部分配置层面的问题。
如果你只是想验证某个模型在终止条件下的行为,比如测试安全拒答是否被正确识别,用模型对话页面最快: https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite 。发一条会触发拒答的请求,观察返回内容是否命中你配置的 indicators。
如果你要把这套 Harness 长期用于编码任务或 Agent 编排,Coding Plan 更合适: https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite 。它和 token 预算终止条件配合,能把单次任务的资源上限控制得更精确。
最后补一个实用技巧:在stopHook 里加一行日志,记录终止时的turnCount和usedTokens。跑一段时间后统计这两个值的分布,如果发现大量任务都在接近maxTurns时才停止,说明你的轮次上限设低了,或者任务拆分粒度太粗。这个数据比任何理论分析都更能指导你调参。