|拆解 Coding Agent 的 harness:從零構(gòu)建你的第一個 AI 編程助手)
1. 為什么你的 Coding Agent 總在第三步崩掉harness 層缺失的典型癥狀很多人第一次寫 AI 編程助手代碼大概長這樣一個 while 循環(huán)把用戶輸入丟給模型模型返回 tool_call 就執(zhí)行執(zhí)行完把結(jié)果塞回 messages再循環(huán)。跑 demo 沒問題一旦讓它改一個真實倉庫里的文件問題就來了——它會在第三步或第四步開始重復讀同一個文件、忘記前面已經(jīng)改過什么、把cd之后的路徑當成永久生效、甚至在等你確認的時候把整個上下文燒光。這些癥狀看起來像模型不夠聰明實際上幾乎全部出在 harness 層。所謂 harness中文可以理解成挽具或編排腳夫它是包在模型外面那一圈基礎設施狀態(tài)機、上下文管理、權(quán)限門、執(zhí)行隔離、事件流。模型權(quán)重和核心 API 行為是固定的你能工程化的部分幾乎全在 harness 里。我拆過 Claude Code、OpenCode、Pi 這類主流 coding agent 的實現(xiàn)得出一個反直覺的結(jié)論真正讓 agent 可用的不是那個 ReAct 循環(huán)而是循環(huán)外面的東西。一個 bare agent loop 大概 20 行就能寫完但一個能跑真實項目的 harness 需要處理 phase machine、steering queue、permission gate、sandbox、context compaction、memory 注入、可觀測性這一整套。這篇文章面向想理解 Agent 如何調(diào)度工具與上下文的開發(fā)者。我會帶你把 harness 拆成可復制的配置片段最后用一個端到端請求驗證整條鏈路真的通了。你不需要先讀完所有源碼跟著配置走一遍黑箱就透明了。先明確邊界。Agent 層負責想模型評估狀態(tài)、選擇 action 或 tool call、接收 observation、迭代。Harness 層負責活它驅(qū)動 turn 的執(zhí)行、管理輸入隊列、攔截危險操作、隔離命令執(zhí)行、壓縮上下文、把事件流分發(fā)給界面。Interface 層負責看TUI 或 headless 遠程執(zhí)行。三層分離之后你換模型、換界面、換沙箱harness 邏輯都不用重寫。下面這張對照表幫你快速定位自己卡在哪一層癥狀大概率出問題的層典型原因重復讀同一文件Harness / Context沒有 compaction歷史里全是舊 tool 結(jié)果改完文件又改回去Harness / Memory沒有把已改事實寫回上下文命令執(zhí)行后路徑丟失Harness / Sandboxfresh-exec 模式下 cd 不持久但代碼假設它持久危險命令直接執(zhí)行Harness / Permission沒有 permission gate 或 gate 規(guī)則寫反等待確認時卡死Harness / Queue單隊列阻塞沒有 steering 與 follow-up 分流長任務中途斷掉無法恢復Harness / Runtime沒有 durable checkpoint看清這張表你就知道接下來該配什么。2. TaoToken 前置給 harness 一個穩(wěn)定的模型入口harness 要跑起來第一件事是讓模型調(diào)用這條鏈路穩(wěn)定。我試過把模型入口寫死在代碼里結(jié)果每次換模型都要改源碼、重跑測試非常痛苦。正確做法是把 Base URL、Key、Model ID 三件套抽成配置harness 只讀配置不關(guān)心供應商。TaoToken 在這里的角色是提供統(tǒng)一的模型調(diào)用入口。官網(wǎng)地址是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 端點是 https://taotoken.net/api 。注意 API 地址不帶 UTM 參數(shù)配置里寫干凈的 endpoint 就行。你需要準備三樣東西缺一不可Base URLhttps://taotoken.net/api這是所有請求的前綴harness 里的 model client 指向它。API Key在控制臺創(chuàng)建形如sk-開頭的一串。這個 Key 只放在環(huán)境變量或本地配置文件里絕對不要提交到 git。我見過有人把 Key 寫進settings.py然后推到公開倉庫十分鐘內(nèi)就被掃走了。Model ID具體調(diào)用哪個模型。harness 的配置里要顯式聲明不要依賴默認值否則換環(huán)境時行為會漂移。獲取 Key 的入口在控制臺的 API Keys 頁面地址是 https://taotoken.net/console/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite 。創(chuàng)建之后復制一次頁面刷新就看不到了先存到本地.env。如果你只是想先驗證模型能不能通不想寫代碼可以用模型對話頁面直接發(fā)一條消息地址是 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 可能觸發(fā)十幾次模型請求按次計費會很難受。接入文檔在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 里面有各語言的調(diào)用示例。我建議你先照著文檔跑通一個最小請求再把它塞進 harness。這里有個容易踩的坑harness 里的 model client 通常需要兼容 OpenAI 風格的/chat/completions或 Anthropic 風格的/messages。TaoToken 的 API 端點支持標準協(xié)議你在配置里把 base_url 指對剩下的交給 SDK。不要自己手寫 HTTP 拼接容易在 header 和 body 格式上出錯。環(huán)境變量建議這樣組織harness 啟動時統(tǒng)一讀取# .env 本地文件不要提交 TAOTOKEN_BASE_URLhttps://taotoken.net/api TAOTOKEN_API_KEYsk-你的key TAOTOKEN_MODEL_ID你的模型ID然后在代碼里用os.environ或pydantic-settings讀取。這樣你的 harness 代碼里不會出現(xiàn)任何硬編碼的 Key換環(huán)境只改.env。3. 可復制配置把 harness 三件套寫進 settings這一節(jié)給你可以直接抄的配置片段。harness 的配置分三塊模型入口、權(quán)限門、沙箱。我按文件路徑組織你照著建目錄就行。先建項目結(jié)構(gòu)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 # 從環(huán)境變量讀不寫明文 model_id 你的模型ID timeout_s 120 max_retries 3 [harness] # 上下文窗口預算compaction 閾值基于它計算 context_window_tokens 200000 # 保留最近多少 token 不壓縮 keep_recent_tokens 40000 # 觸發(fā) microcompaction 的預留比例 microcompaction_reserve_fraction 0.15 # 觸發(fā)完整 compaction 的預留比例 compaction_reserve_fraction 0.20 [sandbox] # none | docker | modal mode docker image python:3.12-slim exec_timeout_s 60權(quán)限門配置寫在config/permissions.json。規(guī)則順序很重要先走 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 自動化絕不能在有真實憑證的環(huán)境里開。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 這類現(xiàn)成工具配置位置不一樣但三件套邏輯相同。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 這類多配置切換工具也遵循同樣結(jié)構(gòu)。它的配置文件里每個 profile 就是一組三件套切換 profile 等于換模型入口。如果你同時用多個模型做對比這個結(jié)構(gòu)能省很多事。配置寫完先別急著跑 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. 端到端驗證一次請求看清執(zhí)行鏈路配置就緒后跑一次完整的 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是關(guān)鍵一個 turn 可能包含多個 legiter → deferred pause → resume → follow-upphase 在第一個 await 之前同步設置保證狀態(tài)查詢不會看到中間態(tài)# 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 # 下一次模型調(diào)用前 drain steering WOULD_STOP would_stop # drain follow-up空則回 idle dataclass class Runner: phase: Phase Phase.IDLE _abort_flag: bool False def dispatch(self): # 同步設置避免競態(tài) self.phase Phase.DISPATCHING self._abort_flag False def mark_running(self): self.phase Phase.RUNNING def request_abort(self): # 協(xié)作式 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)現(xiàn)在寫主循環(huán)把事件打出來。這是驗證 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: 執(zhí)行命令, 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{}) # 這里接真實工具執(zhí)行示例用占位 result f[{call.function.name} 執(zhí)行完成] 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 執(zhí)行完成]) [AssistantTextDelta] AssistantTextDelta(text這個項目是一個...) [TurnFinished] TurnFinished(turn_idt1, stop_reasoncompleted)這條事件序列就是 harness 的心電圖。你能清楚看到turn 開始、工具被調(diào)用、結(jié)果返回、模型生成文本、turn 結(jié)束。如果中間某一步缺失問題就定位到了具體環(huán)節(jié)。驗證成功的標志有三個事件按順序出現(xiàn)、stop_reason是completed而不是max_legs、工具結(jié)果被正確回填到 messages。三個都滿足說明你的 harness 主鏈路通了。5. 本篇常見錯排查401、local proxy failed、reading choices、OAuth配置和驗證過程中報錯幾乎都集中在這幾類。我按真實報錯信息給你對照排查。401 Unauthorized / invalid api key最常見。原因通常是 Key 沒讀到、Key 寫錯、或者 base_url 和 Key 不匹配。先確認環(huán)境變量真的加載了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這個報錯說明請求根本沒發(fā)出去卡在本地網(wǎng)絡層。檢查三件事base_url 是否拼錯、本機是否有殘留的代理環(huán)境變量、DNS 是否能解析。用 curl 直接測curl -sS -o /dev/null -w %{http_code}\n https://taotoken.net/api返回 200 或 401 都說明網(wǎng)絡通返回 000 說明連接失敗。如果本機有HTTP_PROXY之類的環(huán)境變量先 unset 再試。注意不要在代碼里硬編碼任何代理地址harness 應該直連配置的 base_url。reading choices of undefined / KeyError: choices這個報錯幾乎都是響應結(jié)構(gòu)不符合預期。可能原因模型 ID 寫錯導致返回了錯誤對象、SDK 版本和 API 協(xié)議不匹配、或者請求體格式不對。先打印完整響應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 流程的工具報錯可能來自它的登錄態(tài)而不是你的 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 相關(guān)錯誤。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 永遠觸發(fā)不了因為保留區(qū)就占滿了。經(jīng)驗值是保留區(qū)占窗口的 20% 到 30%觸發(fā)閾值設在 80% 左右給模型響應和后續(xù) tool output 留 headroom。工具執(zhí)行卡死 / 等待確認無響應這是隊列設計問題。如果你只有一個隊列等待用戶確認時會阻塞整個循環(huán)。正確做法是 permission gate 返回 ASK 時tool 拋出 ApprovalRequired循環(huán)暫停并返回 deferred 狀態(tài)通過獨立的 decision channel 等待用戶輸入。用戶輸入 y/n/a 后 resolve future循環(huán)恢復。這樣等待期間不占用計算資源也不會死鎖。排查完這幾類你的 harness 基本就穩(wěn)了。每次遇到新報錯先看它屬于哪一層認證層、網(wǎng)絡層、協(xié)議層、還是 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 向上遍歷到文件系統(tǒng)根收集 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。兩級級聯(lián)microcompaction 不調(diào)模型只把舊的 tool 輸出體替換成占位符完整 compaction 調(diào)一次便宜的模型把老歷史總結(jié)成固定骨架。觸發(fā)閾值基于 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沙箱層按需開啟。本地開發(fā)用mode none跑真實命令時切docker。fresh-exec 模式下每條命令作為全新進程運行cd和export不會跨調(diào)用持久化。這個設計看起來反直覺但它讓本地和遠程行為字節(jié)級一致避免本地能跑遠程不能跑的問題# 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 場景下穩(wěn)定的計費和額度比單次便宜更重要因為 harness 一個 turn 可能觸發(fā)十幾次模型請求。接入文檔在 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方便按項目排查和吊銷。最后說一個我踩過的坑不要一上來就把所有組件都打開。先跑通模型入口再加事件流再加權(quán)限門最后加沙箱和 compaction。每加一層就跑一次端到端驗證確認事件序列沒變。這樣出問題時你永遠知道是哪一層引入的。harness 的復雜度是必要的但引入復雜度必須可控。