LLM之Agent(六十一)|拆解 Coding Agent 的 harness:从零构建你的第一个 AI 编程助手
发布时间:2026/10/2 16:25:15来源:尧图网络
1. 为什么你的 Coding Agent 总在第三步崩掉harness 层缺失的典型症状很多人第一次写 AI 编程助手代码大概长这样一个 while 循环把用户输入丢给模型模型返回 tool_call 就执行执行完把结果塞回 messages再循环。跑 demo 没问题一旦让它改一个真实仓库里的文件问题就来了——它会在第三步或第四步开始重复读同一个文件、忘记前面已经改过什么、把cd之后的路径当成永久生效、甚至在等你确认的时候把整个上下文烧光。这些症状看起来像模型不够聪明实际上几乎全部出在 harness 层。所谓 harness中文可以理解成挽具或编排脚夫它是包在模型外面那一圈基础设施状态机、上下文管理、权限门、执行隔离、事件流。模型权重和核心 API 行为是固定的你能工程化的部分几乎全在 harness 里。我拆过 Claude Code、OpenCode、Pi 这类主流 coding agent 的实现得出一个反直觉的结论真正让 agent 可用的不是那个 ReAct 循环而是循环外面的东西。一个 bare agent loop 大概 20 行就能写完但一个能跑真实项目的 harness 需要处理 phase machine、steering queue、permission gate、sandbox、context compaction、memory 注入、可观测性这一整套。这篇文章面向想理解 Agent 如何调度工具与上下文的开发者。我会带你把 harness 拆成可复制的配置片段最后用一个端到端请求验证整条链路真的通了。你不需要先读完所有源码跟着配置走一遍黑箱就透明了。先明确边界。Agent 层负责想模型评估状态、选择 action 或 tool call、接收 observation、迭代。Harness 层负责活它驱动 turn 的执行、管理输入队列、拦截危险操作、隔离命令执行、压缩上下文、把事件流分发给界面。Interface 层负责看TUI 或 headless 远程执行。三层分离之后你换模型、换界面、换沙箱harness 逻辑都不用重写。下面这张对照表帮你快速定位自己卡在哪一层症状大概率出问题的层典型原因重复读同一文件Harness / Context没有 compaction历史里全是旧 tool 结果改完文件又改回去Harness / Memory没有把已改事实写回上下文命令执行后路径丢失Harness / Sandboxfresh-exec 模式下 cd 不持久但代码假设它持久危险命令直接执行Harness / Permission没有 permission gate 或 gate 规则写反等待确认时卡死Harness / Queue单队列阻塞没有 steering 与 follow-up 分流长任务中途断掉无法恢复Harness / Runtime没有 durable checkpoint看清这张表你就知道接下来该配什么。2. TaoToken 前置给 harness 一个稳定的模型入口harness 要跑起来第一件事是让模型调用这条链路稳定。我试过把模型入口写死在代码里结果每次换模型都要改源码、重跑测试非常痛苦。正确做法是把 Base URL、Key、Model ID 三件套抽成配置harness 只读配置不关心供应商。TaoToken 在这里的角色是提供统一的模型调用入口。官网地址是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 端点是 https://taotoken.net/api 。注意 API 地址不带 UTM 参数配置里写干净的 endpoint 就行。你需要准备三样东西缺一不可Base URLhttps://taotoken.net/api这是所有请求的前缀harness 里的 model client 指向它。API Key在控制台创建形如sk-开头的一串。这个 Key 只放在环境变量或本地配置文件里绝对不要提交到 git。我见过有人把 Key 写进settings.py然后推到公开仓库十分钟内就被扫走了。Model ID具体调用哪个模型。harness 的配置里要显式声明不要依赖默认值否则换环境时行为会漂移。获取 Key 的入口在控制台的 API Keys 页面地址是 https://taotoken.net/console/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite 。创建之后复制一次页面刷新就看不到了先存到本地.env。如果你只是想先验证模型能不能通不想写代码可以用模型对话页面直接发一条消息地址是 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodel_chatutm_campaignrewrite 。这一步能帮你排除是 Key 错了还是 harness 写错了的干扰。对于长期跑编码任务或 Agent 工作流的场景Coding Plan 更合适入口在 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite 。它的计费方式对高频 tool call 更友好因为 coding agent 一个 turn 可能触发十几次模型请求按次计费会很难受。接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 里面有各语言的调用示例。我建议你先照着文档跑通一个最小请求再把它塞进 harness。这里有个容易踩的坑harness 里的 model client 通常需要兼容 OpenAI 风格的/chat/completions或 Anthropic 风格的/messages。TaoToken 的 API 端点支持标准协议你在配置里把 base_url 指对剩下的交给 SDK。不要自己手写 HTTP 拼接容易在 header 和 body 格式上出错。环境变量建议这样组织harness 启动时统一读取# .env 本地文件不要提交 TAOTOKEN_BASE_URLhttps://taotoken.net/api TAOTOKEN_API_KEYsk-你的key TAOTOKEN_MODEL_ID你的模型ID然后在代码里用os.environ或pydantic-settings读取。这样你的 harness 代码里不会出现任何硬编码的 Key换环境只改.env。3. 可复制配置把 harness 三件套写进 settings这一节给你可以直接抄的配置片段。harness 的配置分三块模型入口、权限门、沙箱。我按文件路径组织你照着建目录就行。先建项目结构my-agent/ config/ settings.toml permissions.json src/ harness/ runner.py queue.py gate.py agent/ loop.py模型入口配置写在config/settings.toml。TOML 比 JSON 更适合写配置因为支持注释# config/settings.toml [llm] base_url https://taotoken.net/api api_key_env TAOTOKEN_API_KEY # 从环境变量读不写明文 model_id 你的模型ID timeout_s 120 max_retries 3 [harness] # 上下文窗口预算compaction 阈值基于它计算 context_window_tokens 200000 # 保留最近多少 token 不压缩 keep_recent_tokens 40000 # 触发 microcompaction 的预留比例 microcompaction_reserve_fraction 0.15 # 触发完整 compaction 的预留比例 compaction_reserve_fraction 0.20 [sandbox] # none | docker | modal mode docker image python:3.12-slim exec_timeout_s 60权限门配置写在config/permissions.json。规则顺序很重要先走 deny再走 allow最后落到 mode 默认行为{ mode: default, rules: [ { match: { tool: bash, command_regex: rm\\s-rf\\s/ }, decision: deny, reason: 禁止删除根目录 }, { match: { tool: bash, command_regex: git\\spush }, decision: ask, reason: 推送需要人工确认 }, { match: { tool: read }, decision: allow }, { match: { tool: glob }, decision: allow }, { match: { tool: grep }, decision: allow }, { match: { tool: write }, decision: ask }, { match: { tool: edit }, decision: ask } ] }注意mode字段。default模式下只读工具自动放行写文件和 bash 需要确认edit模式下文件编辑自动放行bash 仍然要问bypass模式全部放行只用于 headless 自动化绝不能在有真实凭证的环境里开。harness 读取配置的代码长这样用 pydantic 做校验字段缺失直接报错而不是静默用默认值# src/harness/config.py from pathlib import Path import json import tomllib from pydantic import BaseModel, Field class LLMConfig(BaseModel): base_url: str api_key_env: str model_id: str timeout_s: int 120 max_retries: int 3 class HarnessConfig(BaseModel): context_window_tokens: int 200_000 keep_recent_tokens: int 40_000 microcompaction_reserve_fraction: float 0.15 compaction_reserve_fraction: float 0.20 class SandboxConfig(BaseModel): mode: str none image: str python:3.12-slim exec_timeout_s: int 60 class Settings(BaseModel): llm: LLMConfig harness: HarnessConfig sandbox: SandboxConfig def load_settings(root: Path) - Settings: with open(root / config / settings.toml, rb) as f: raw tomllib.load(f) return Settings(**raw) def load_permissions(root: Path) - dict: with open(root / config / permissions.json, r, encodingutf-8) as f: return json.load(f)如果你用的是 Claude Code 或 Cline 这类现成工具配置位置不一样但三件套逻辑相同。Claude Code 的 settings 里要写ANTHROPIC_BASE_URL、ANTHROPIC_API_KEY、ANTHROPIC_MODELCline 的 MCP 配置里要写baseUrl、apiKey、model。不管哪个工具Base URL、Key、Model ID 三个字段一个都不能少少一个就会在第一次请求时报 401 或 model not found。CC Switch 这类多配置切换工具也遵循同样结构。它的配置文件里每个 profile 就是一组三件套切换 profile 等于换模型入口。如果你同时用多个模型做对比这个结构能省很多事。配置写完先别急着跑 agent。用一段最小代码验证模型入口通不通# scripts/check_llm.py import os from openai import OpenAI from pathlib import Path from src.harness.config import load_settings settings load_settings(Path(.)) client OpenAI( base_urlsettings.llm.base_url, api_keyos.environ[settings.llm.api_key_env], ) resp client.chat.completions.create( modelsettings.llm.model_id, messages[{role: user, content: 只回复两个字通了}], timeoutsettings.llm.timeout_s, ) print(resp.choices[0].message.content)跑python scripts/check_llm.py输出通了就说明模型入口没问题。这一步能帮你把模型问题和 harness 问题彻底分开。4. 端到端验证一次请求看清执行链路配置就绪后跑一次完整的 turn观察事件流。harness 的价值在于把黑箱变成可观测的事件序列。我设计一个最小验证场景让 agent 读一个文件、改一个文件、跑一条命令全程打印事件。先写事件定义。事件是 frozen 且 hashable 的这样 TUI 和远程可观测性可以共用同一个真实来源# src/harness/events.py from dataclasses import dataclass from typing import Union dataclass(frozenTrue) class TurnStarted: turn_id: str dataclass(frozenTrue) class AssistantTextDelta: text: str dataclass(frozenTrue) class ToolCallStarted: tool: str args: dict dataclass(frozenTrue) class ToolResult: tool: str ok: bool preview: str dataclass(frozenTrue) class PermissionRequested: tool: str reason: str dataclass(frozenTrue) class ContextCompacted: before_tokens: int after_tokens: int dataclass(frozenTrue) class TurnFinished: turn_id: str stop_reason: str Event Union[ TurnStarted, AssistantTextDelta, ToolCallStarted, ToolResult, PermissionRequested, ContextCompacted, TurnFinished, ]然后是 runner 的 phase machine。单飞single-flight是关键一个 turn 可能包含多个 legiter → deferred pause → resume → follow-upphase 在第一个 await 之前同步设置保证状态查询不会看到中间态# src/harness/runner.py import enum from dataclasses import dataclass, field class Phase(enum.Enum): IDLE idle DISPATCHING dispatching # 第一个 await 之前的同步窗口 RUNNING running class Boundary(enum.Enum): MODEL_REQUEST model_request # 下一次模型调用前 drain steering WOULD_STOP would_stop # drain follow-up空则回 idle dataclass class Runner: phase: Phase Phase.IDLE _abort_flag: bool False def dispatch(self): # 同步设置避免竞态 self.phase Phase.DISPATCHING self._abort_flag False def mark_running(self): self.phase Phase.RUNNING def request_abort(self): # 协作式 abort设置 flagturn 在下一个 boundary 停止 self._abort_flag True def should_abort(self) - bool: return self._abort_flag双队列交互模型是防止 mid-turn 破坏的核心。用户按 Enter 的消息进 steering 队列在下一个 model-request 边界注入按 AltEnter 的消息进 follow-up 队列只在 WOULD_STOP 边界处理# src/harness/queue.py import asyncio from dataclasses import dataclass, field def _drain(q: asyncio.Queue) - list[str]: out [] while not q.empty(): out.append(q.get_nowait()) return out dataclass class InteractionQueues: steering: asyncio.Queue field(default_factoryasyncio.Queue) follow_up: asyncio.Queue field(default_factoryasyncio.Queue) def drain_steering(self) - list[str]: return _drain(self.steering) def drain_follow_up(self) - list[str]: return _drain(self.follow_up)现在写主循环把事件打出来。这是验证 harness 是否工作的核心# src/harness/main.py import asyncio import os from pathlib import Path from openai import AsyncOpenAI from src.harness.config import load_settings, load_permissions from src.harness.runner import Runner, Phase, Boundary from src.harness.queue import InteractionQueues from src.harness.events import ( TurnStarted, AssistantTextDelta, ToolCallStarted, ToolResult, TurnFinished, ) async def run_turn(prompt: str, settings, queues, runner): client AsyncOpenAI( base_urlsettings.llm.base_url, api_keyos.environ[settings.llm.api_key_env], ) runner.dispatch() yield TurnStarted(turn_idt1) messages [{role: user, content: prompt}] tools [ {type: function, function: { name: read, description: 读文件, parameters: {type: object, properties: { path: {type: string}}, required: [path]}}}, {type: function, function: { name: bash, description: 执行命令, parameters: {type: object, properties: { command: {type: string}}, required: [command]}}}, ] for leg in range(8): # MODEL_REQUEST 边界注入 steering for msg in queues.drain_steering(): messages.append({role: user, content: msg}) runner.mark_running() resp await client.chat.completions.create( modelsettings.llm.model_id, messagesmessages, toolstools, timeoutsettings.llm.timeout_s, ) choice resp.choices[0].message if choice.content: yield AssistantTextDelta(textchoice.content) if not choice.tool_calls: # WOULD_STOP 边界处理 follow-up follow queues.drain_follow_up() if follow: for msg in follow: messages.append({role: user, content: msg}) continue yield TurnFinished(turn_idt1, stop_reasoncompleted) runner.phase Phase.IDLE return messages.append(choice) for call in choice.tool_calls: yield ToolCallStarted(toolcall.function.name, args{}) # 这里接真实工具执行示例用占位 result f[{call.function.name} 执行完成] yield ToolResult(toolcall.function.name, okTrue, previewresult[:80]) messages.append({ role: tool, tool_call_id: call.id, content: result, }) yield TurnFinished(turn_idt1, stop_reasonmax_legs) async def main(): settings load_settings(Path(.)) queues InteractionQueues() runner Runner() async for ev in run_turn(读一下 README.md 然后告诉我项目是做什么的, settings, queues, runner): print(f[{type(ev).__name__}] {ev}) if __name__ __main__: asyncio.run(main())跑起来你会看到类似这样的输出[TurnStarted] TurnStarted(turn_idt1) [ToolCallStarted] ToolCallStarted(toolread, args{}) [ToolResult] ToolResult(toolread, okTrue, preview[read 执行完成]) [AssistantTextDelta] AssistantTextDelta(text这个项目是一个...) [TurnFinished] TurnFinished(turn_idt1, stop_reasoncompleted)这条事件序列就是 harness 的心电图。你能清楚看到turn 开始、工具被调用、结果返回、模型生成文本、turn 结束。如果中间某一步缺失问题就定位到了具体环节。验证成功的标志有三个事件按顺序出现、stop_reason是completed而不是max_legs、工具结果被正确回填到 messages。三个都满足说明你的 harness 主链路通了。5. 本篇常见错排查401、local proxy failed、reading choices、OAuth配置和验证过程中报错几乎都集中在这几类。我按真实报错信息给你对照排查。401 Unauthorized / invalid api key最常见。原因通常是 Key 没读到、Key 写错、或者 base_url 和 Key 不匹配。先确认环境变量真的加载了python -c import os; print(os.environ.get(TAOTOKEN_API_KEY, NOT SET)[:8])如果输出NOT SET说明.env没被加载。Python 不会自动读.env你需要python-dotenv或手动 export。如果输出了前 8 位但请求还是 401检查 base_url 是否写成了带路径的形式比如https://taotoken.net/api/v1有些 SDK 会自己拼/v1重复拼接就会 404 或 401。正确写法是只写到https://taotoken.net/api。local proxy failed / connection refused这个报错说明请求根本没发出去卡在本地网络层。检查三件事base_url 是否拼错、本机是否有残留的代理环境变量、DNS 是否能解析。用 curl 直接测curl -sS -o /dev/null -w %{http_code}\n https://taotoken.net/api返回 200 或 401 都说明网络通返回 000 说明连接失败。如果本机有HTTP_PROXY之类的环境变量先 unset 再试。注意不要在代码里硬编码任何代理地址harness 应该直连配置的 base_url。reading choices of undefined / KeyError: choices这个报错几乎都是响应结构不符合预期。可能原因模型 ID 写错导致返回了错误对象、SDK 版本和 API 协议不匹配、或者请求体格式不对。先打印完整响应resp await client.chat.completions.create(...) print(resp.model_dump_json(indent2))如果返回体里是{error: {...}}而不是{choices: [...]}那就是请求本身被拒了去看 error 字段的具体信息。常见的是 model not found说明 Model ID 和账号可用模型不匹配去控制台确认一下。OAuth / authentication failed / token expired如果你用的是 Claude Code 这类带 OAuth 流程的工具报错可能来自它的登录态而不是你的 API Key。这类工具通常有两套认证一套是工具自身的账号登录一套是模型 API 的 Key。两者不能混。检查工具的配置文件里模型入口是否指向了正确的 base_url 和 Key。Claude Code 的配置在~/.claude/settings.jsonCline 的在 VS Code 设置里Codex 的在~/.codex/auth.json。以 Codex 的auth.json为例三件套要写全{ base_url: https://taotoken.net/api, api_key: sk-你的key, model: 你的模型ID }少任何一个字段工具都会回退到默认认证流程然后报 OAuth 相关错误。Cline 的 MCP 配置同理baseUrl、apiKey、model三个字段缺一不可。CC Switch 切换 profile 时如果某个 profile 只填了两个字段切过去就会认证失败。上下文超限 / context length exceeded这个不是认证问题是 harness 的 compaction 没生效。检查context_window_tokens是否和实际模型窗口一致keep_recent_tokens是否设得太大。如果keep_recent_tokens接近context_window_tokenscompaction 永远触发不了因为保留区就占满了。经验值是保留区占窗口的 20% 到 30%触发阈值设在 80% 左右给模型响应和后续 tool output 留 headroom。工具执行卡死 / 等待确认无响应这是队列设计问题。如果你只有一个队列等待用户确认时会阻塞整个循环。正确做法是 permission gate 返回 ASK 时tool 抛出 ApprovalRequired循环暂停并返回 deferred 状态通过独立的 decision channel 等待用户输入。用户输入 y/n/a 后 resolve future循环恢复。这样等待期间不占用计算资源也不会死锁。排查完这几类你的 harness 基本就稳了。每次遇到新报错先看它属于哪一层认证层、网络层、协议层、还是 harness 逻辑层。分层之后排查范围立刻缩小。6. 把 harness 用起来从验证到长期编码主链路通了之后你可以按需扩展。harness 的每个组件都是可插拔的不用一次全上。先加 memory 注入。在项目根目录放AGENTS.mdharness 启动时读取并注入到 system prompt。这样 agent 每次都知道项目约定不用你重复交代# src/harness/memory.py from pathlib import Path def assemble_memory(cwd: Path) - str: blocks [] for path in discover_memory_files(cwd): content path.read_text(encodingutf-8, errorsignore) if path.name MEMORY.md: content \n.join(content.splitlines()[:200]) blocks.append(f# From {path}\n{content}) return \n\n.join(blocks) def discover_memory_files(cwd: Path): # 从 cwd 向上遍历到文件系统根收集 AGENTS.md 和 MEMORY.md current cwd.resolve() found [] while True: for name in (AGENTS.md, MEMORY.md): candidate current / name if candidate.exists(): found.append(candidate) if current.parent current: break current current.parent return list(reversed(found))再加 context compaction。两级级联microcompaction 不调模型只把旧的 tool 输出体替换成占位符完整 compaction 调一次便宜的模型把老历史总结成固定骨架。触发阈值基于 token 预算# src/harness/compaction.py import enum class CompactOutcome(enum.Enum): COMPACTED compacted NOTHING_TO_COMPACT nothing_to_compact SUMMARIZER_FAILED summarizer_failed def split_tail(messages, *, keep_recent_tokens: int) - int: 从尾部累积 tokensnap 到 compaction boundary 保证 tool-call/result 对不被拆开。 total 0 for i in range(len(messages) - 1, -1, -1): total estimate_tokens(messages[i]) if total keep_recent_tokens: return snap_to_boundary(messages, i) return 0 def microcompact(messages, *, keep_recent_tokens: int): 无 LLM 层把旧 tool 输出体清空。 boundary split_tail(messages, keep_recent_tokenskeep_recent_tokens) for msg in messages[:boundary]: if msg.get(role) tool: msg[content] [已压缩] return messages沙箱层按需开启。本地开发用mode none跑真实命令时切docker。fresh-exec 模式下每条命令作为全新进程运行cd和export不会跨调用持久化。这个设计看起来反直觉但它让本地和远程行为字节级一致避免本地能跑远程不能跑的问题# src/harness/sandbox.py class SandboxExecutor: Fresh-execcd/export 不持久。 def __init__(self, backend, workspace): self._backend backend self._workspace workspace self._created False async def run(self, command: str, *, timeout_s: float): if not self._created: await self._backend.create(self._workspace) self._created True return await self._backend.exec(bash, -lc, command, timeout_stimeout_s)如果你要跑长期任务或 Agent 工作流建议用 Coding Plan入口在 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite 。高频 tool call 场景下稳定的计费和额度比单次便宜更重要因为 harness 一个 turn 可能触发十几次模型请求。接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 里面有各语言和各工具的完整配置示例。API Keys 管理在 https://taotoken.net/console/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite 建议给不同项目建不同的 Key方便按项目排查和吊销。最后说一个我踩过的坑不要一上来就把所有组件都打开。先跑通模型入口再加事件流再加权限门最后加沙箱和 compaction。每加一层就跑一次端到端验证确认事件序列没变。这样出问题时你永远知道是哪一层引入的。harness 的复杂度是必要的但引入复杂度必须可控。
网站建设高端定制企业官网