跳转至

09. 开发型智能体教材:从 learn-claude-code 理解 Harness

九、开发型 Agent 教材:从 learn-claude-code 理解 Harness

开发型 agent 的最低配置

一个真实代码库交给系统,说”有个测试挂了,先定位原因,再决定是改实现、补测试还是拆成几个子任务继续查”。只会围着这句话生成一段建议,只是更会说话的模型。能真的去读文件、跑命令、根据观察结果改下一步、知道什么时候该停,事情才开始变成 agent。开发型 Agent 的门槛不在”能不能输出代码”,而在”是不是已经进入一个受控循环,能把真实世界里的反馈带回来,再决定下一步”。

learn-claude-code 这份教材的价值:它不从大而全的产品心智起步,先把最小问题压到能看清的程度。它是第三方教学项目,不是 Anthropic 官方实现,也不是 Claude Code 的源码导览。公开可依赖的证据主要是 README、agents/ 目录里那组逐步递进的样本、docs/ 的教程和 web/ 学习站。这条边界守住后,每一个对象被当成”教学上刻意露出来的骨架”,不是”生产系统已经如此定型的铁律”。它是教学样本,适合回答另一个更根本的问题:不靠框架名词,一个开发型 harness 到底最少需要什么,才不至于塌成一段长 prompt。

真正的最小集合并不神秘。一个目标,知道这次在解决哪个代码问题。一组能进真实环境的工具,读文件、搜代码、跑命令、执行测试。一份会被每轮更新的状态,知道前面看到了什么、做到了哪一步。清楚的停止条件。这几样东西放在一起,最小闭环就立住了:

flowchart LR A[“任务目标”] --> B[“当前状态”] B --> C[“模型判断下一步”] C --> D[“读文件 / 搜索 / 跑命令”] D --> E[“观察结果写回状态”] E --> F{“已经满足停止条件?”} F -- 否 --> B F -- 是 --> G[“收口输出”]

这个图看起来简单,已经把开发型 agent 和普通聊天分开了。聊天只需要”上下文 + 一次生成”,这里多出来的是决策、执行、回写和收口。第一次学到这里,容易犯的错是把”带工具调用的模型接口”误当成 harness 本身。真正站住的是循环:模型只负责决定下一步,代码负责提供环境、执行动作、回填结果,并且在必要时强行停止。

最小闭环从单轮对话里长出来

这套教学路径最早先站住的是最小闭环,而且站得很素。不是先引入复杂状态图,也不是先把规划和多代理都抬出来,而是先让系统能做一件朴素的事:看当前状态,决定要不要调用一个工具,拿到结果之后再继续。起点够低,更容易看见开发型 harness 的本质不在”高级抽象”,在”模型是不是已经开始根据环境反馈做下一步决策”。

最小 loop 压成伪代码:

state = {
    "goal": "修掉 failing test,并确认是否需要补回归测试",
    "messages": [],
    "artifacts": [],
}

while True:
    decision = model.decide(state)
    if decision.type == "stop":
        break

    result = run_tool(decision.tool, decision.args)
    state["messages"].append({"decision": decision, "result": result})
    state["artifacts"].append(result.summary)

这里最重要的是角色分工清楚。模型不直接修改文件系统,不直接”拥有世界”。它根据 state 判断下一步该做什么。真正去读文件、跑测试、返回 stdout、解析错误、决定哪些结果值得写回状态的,都是 harness。”模型是 agent,代码是环境”这句话在强调:在开发型系统里,模型承担决策,代码承担环境暴露、动作执行、结果回填和边界收束。

第二层能力被加进来时,判断更清楚。工具的增长意味着同一条循环能调用更多动作,不是再做一个新 agent。新的能力面在 dispatch 层多注册一个 handler:

TOOLS = {
    "read_file": read_file,
    "search_code": search_code,
    "run_command": run_command,
    "run_tests": run_tests,
}

def run_tool(name, args):
    handler = TOOLS[name]
    return handler(**args)

这一层看着平淡,实际很关键,决定了系统后面还能不能继续长。每加一个工具都要改一套新链路时,系统很快就会散成一堆互不相通的小机器人。工具只是注册在同一条控制线里时,复杂度增长的主语还是这条 loop,不是旁边不断复制出来的平行系统。教学样本在提醒:在最小阶段,真正该先站住的是这条循环已经有了清楚的控制权分工,不是框架。

这个阶段还远远不够。任务稍微复杂一点,先定位失败测试,再找调用链,再判断是实现问题还是测试夹具问题,单纯的一问一答式 loop 就会开始飘。系统跑出来的不是”执行链”,更像一连串局部聪明的临场反应。复杂任务暴露出一个更深的事实:只靠当前上下文即时判断,很多长任务根本没有稳定骨架。

长任务逼出计划、子任务、技能和上下文整理

系统处理像样一点的开发任务时,”骨架不够”的感觉非常明显。先跑了失败测试,发现报错落在某个 service,再去搜调用点,又顺手打开了几份测试夹具,然后想起也许应该查一下项目里的测试约定,再去翻规则文件。最危险的地方不是模型会不会写代码,而是整件事仍然只有它自己”心里知道”下一步准备做什么。中间观察多几次后,原本的方向会慢慢埋进越来越厚的上下文里,任务表面上还在推进,实际越来越难恢复、越来越难解释、越来越难判断是不是在浪费步数。

教学路径的中段,最先被逼出来的是计划对象。不需要一上来就长成复杂 DAG,至少把”当前任务已经拆成了哪些可执行块”从模型脑内抬出来。loop 的主语发生一个细小却关键的变化:模型不再只是”看完状态以后随便决定下一步”,而是在一份显式任务表前做选择、推进和回写。伪代码看起来只多了几行,运行时性质完全不同:

state["plan"] = [
    {"id": 1, "task": "运行失败测试并记录报错", "status": "pending"},
    {"id": 2, "task": "定位相关实现和调用链", "status": "pending"},
    {"id": 3, "task": "决定是改实现还是补测试", "status": "pending"},
]

while True:
    current = next_pending_item(state["plan"])
    if current is None:
        break

    decision = model.decide(state, current)
    result = run_tool(decision.tool, decision.args)
    update_plan(state["plan"], current["id"], result)
    write_back(state, result)

把 todo 当成展示层装饰,界面上出现了几个待办就以为”有 planning”,是很容易犯的错。关键是这份计划是不是已经反过来约束了执行路径。不能决定当前优先处理哪一块、失败后回到哪一块、哪些步骤已经完成、哪些证据需要补齐时,它仍然只是看起来更整齐的聊天记录,不是正式对象。

计划对象出现后,新问题立刻冒出来。长任务里最贵的东西不是单步推理,而是上下文污染。主循环为了定位 bug,已经看了测试输出、实现文件、配置、规则说明和几段历史结果。再让它顺手去做一个子问题,如果还背着整锅历史进去,子任务就很难真正变轻。子任务边界被逼出来,价值不是”再开一个模型”,而是给特定子问题一份更干净、更窄的上下文,再把结果作为产物带回来。

subtask_input = {
    "goal": "检查 test fixture 初始化是否导致失败",
    "files": ["tests/order_test.py", "tests/fixtures/order.json"],
    "constraints": ["不要修改主分支代码", "只输出判断依据和建议"],
}

sub_result = run_subagent(subtask_input)
state["artifacts"].append(sub_result.summary)
state["messages"].append({"subtask": sub_result})

多开一个模型不叫子任务拆分,上下文还是同一锅历史时,复杂度没有真的下降。subagent 的价值在于隔离:只带子问题真正需要的目标、文件、约束和输入材料,不把所有东西都带过去。返回的不是大坨未加工对话,而是一个能重新进入主循环的结果对象。把它理解成”主循环临时雇了一个只做某件事的执行单元”,比理解成”又聊了一次天”更准确。

同一时期长出来的,还有技能和上下文整理。技能之所以会出现,不是因为 prompt 不够长,而是因为长任务里总有一些稳定工作方法反复出现——“先看失败测试,再看最近修改,再决定是否跑更大范围回归”。这些知识每次都常驻在主 prompt 里,上下文很快被方法说明挤满。默认做法是把它们变成按需装配的能力包,在当前任务真的需要时再加载。技能的主语是”知识何时进入执行链、以什么边界进入执行链”,不是”额外多一段说明”。

上下文整理也是同一个道理。任务一长,历史里既有关键证据,也有大量已失效的中间思路。没有压缩层,系统迟早会把真正有用的状态和低价值噪声混在一起。压缩的意义不是简单省 token,而是把”什么已经确认过””什么仍然悬而未决””哪些结果足以支撑下一步”重新提炼出来。skills 和 compact 在同一个阶段一起出现:前者解决知识怎样按需注入,后者解决观察怎样被压回可继续执行的状态。少了任何一边,长任务很快失去形状。

会话装不下以后要补任务、后台执行和隔离

前一段解决的是”一个会话里的长任务怎样别糊掉”。再往后,问题彻底换一种形态。有些任务并不会在一次会话里完成,有些动作运行时间很长,不适合让主 loop 一直等着,有些子任务已经值得交给不同执行单元并行推进,而一旦真的开始并行,目录和环境污染会比提示词问题更快把系统拖垮。复杂度已经从”上下文组织”升级成”任务对象、执行时长、协作协议和运行环境”。

第一个被逼出来的是任务本身的持久化。计划已经不只是为了当前界面好看,而是要成为可以被 claim、更新、恢复和回放的正式对象。要知道某个任务是否已经被领取,当前做到哪一步,产物放在哪,失败是不是可以稍后恢复。否则系统一旦中断,会话一断,前面的”显式计划”仍然会退化回临时记忆。

task = {
    "id": "task-123",
    "goal": "修掉 failing test 并确认回归范围",
    "status": "queued",
    "owner": None,
    "artifacts": [],
    "depends_on": [],
}

if claim(task, worker="main-loop"):
    task["status"] = "running"
    result = execute(task)
    persist(task, result)

任务对象开始落盘,后台执行自然冒出来。很多动作不值得把主循环卡住:跑全量测试、建立索引、生成大体积 diff 分析、等待外部工具返回。主 loop 更像协调者,不该替每个慢动作无限等待。默认做法是把这些慢步骤丢给后台执行单元,只把状态变更和关键产物带回主循环。”一个聊天窗口从头管到尾”的心智越来越吃力,因为系统真正的生命线不在会话文本里,在任务状态和产物引用里。

协作进入更真实的阶段。前面子任务像”主循环临时借来一个小助手”,后面开始出现稳定分工:有人更擅长读测试,有人更擅长读实现,有人只负责验证和回归。真正先成立的是分工和协议,不是并行。不同执行单元之间开始交换任务、证据和结果时,需要一套显式的消息结构,知道发出去的、收回来的、失败时要不要重试、重复任务怎样去重、旧结果会不会覆盖新状态。

协作一出现,协议几乎一定会跟着出现。不必一开始就做成很重的 A2A 框架,至少让”请求什么、带哪些上下文、返回什么产物、状态如何更新”说得清楚。否则多执行单元把一个人脑内的混乱,放大成几个人互相传染的混乱。协议立住以后,再往前走到更自主的协作才有意义。系统已经知道如何把任务发出去、如何识别回应、如何拒绝旧状态和重复任务,不把”自主”理解成”大家都可以随便开始做事”。

最后压轴出现的不是更复杂的 prompt,而是目录和环境隔离。不同任务真的在同一个代码库上并行推进时,最先爆炸的不是推理质量,而是文件污染、依赖污染、执行结果互相覆盖。任务对象、协议边界和后台执行如果最终还落在同一个工作目录里,很快就会彼此打架。工作目录隔离不是”锦上添花的优化”,而是多任务执行迟早要落下去的一道硬边界。

worktree = create_worktree(base_repo, task_id=task["id"])
task["workspace"] = worktree.path

result = run_in_workspace(
    workspace=worktree.path,
    command="pytest tests/order_test.py -q",
)

collect_artifacts(task, result, workspace=worktree.path)

还停留在同目录并发执行时,前面所有任务对象和协议边界迟早会互相污染。真正成熟的复杂度不是”工具更多了”,而是系统开始认真回答这些问题:哪个任务在哪个目录执行,谁拥有这次修改,回归结果属于谁,失败后从哪里恢复,多个执行单元怎样不踩彼此的现场。这套教学路径后半段最值钱的地方:开发型 harness 一旦往真实执行走,复杂度会不可避免地从 prompt 与上下文,转移到任务、协议和环境边界。

flowchart LR A["最小闭环"] --> B["显式计划"] B --> C["子任务隔离"] C --> D["技能按需装配"] D --> E["上下文压缩"] E --> F["任务落盘"] F --> G["后台执行"] G --> H["多执行单元协作"] H --> I["显式协议"] I --> J["目录与环境隔离"]

这套 harness 教的是生长顺序,不是生产治理

整章读完,回头看这份教材真正做了什么:最珍贵的不是”给出了一套现成框架”,而是把开发型 harness 的生长顺序讲顺了。先站稳最小闭环,再把长任务里的计划、子任务、技能和压缩层扶正,最后承认一次会话已经不够,任务对象、后台执行、协作协议和目录隔离必须继续长出来。这条顺序讲顺后,很多原本在 Claude Code、Codex 这类正式系统里看起来同时冒出来的对象,会重新回到它们该在的位置上。

把这些对象压成一张表,会更容易看清它们各自为什么会出现:

对象 它解决的不是“功能”,而是哪种失控 真正站住以后,主循环多了什么
messages 每轮观察没有历史,系统像失忆 决策开始基于累计观察而不是单轮输入
tool registry 能做什么完全写死在 prompt 里 动作边界进入代码层,可注册、可治理
plan / todo 长任务只存在于模型脑内 当前步骤、完成状态和恢复入口开始显式化
subtask context 子问题背着整锅历史一起跑 子任务开始拿到更干净的输入边界
skill 稳定方法被迫常驻在大 prompt 里 知识开始按需装配,而不是永久驻留
summary / compacted context 长历史把有用状态和噪声混在一起 关键事实被重新提炼回可执行状态
task 会话一断,进度就消失 任务开始可领取、可恢复、可回放
protocol message 多执行单元只靠隐式理解协作 请求、结果和状态同步有了正式载体
worktree binding 并行执行互相污染目录和环境 每个任务终于有了独立执行现场

同样重要的是,这套教材刻意没有解决生产系统里最难的那部分治理。权限模型、sandbox、approval、正式审计、恢复策略、租户边界、长期观测都不是它的主角。它讲的是机制怎样长出来,不是平台怎样上线。读完这一章后最不该做的事:把这套教学样本误听成行业共识手册,或者把它当成 Claude Code 的替代源码,再或者觉得”既然最小 loop 已经能跑,生产治理以后慢慢补就行”。这份样本只把骨架拆给你看,方便你知道哪些对象为什么会出现。正式系统的难点,在于把这些对象接回权限、审批、观测、隔离和恢复。

学完以后要固定一套排查顺序,沿着这条生长线往回查。先看最小闭环是不是站住了,模型的决策和代码的执行边界是不是已经清楚。再看计划对象是不是真的在改变路径,而不是只起展示作用。接着看子任务有没有真正拿到干净上下文,技能是不是按需装配而不是换个地方继续堆 prompt。问题已经进入长任务和多执行单元时,再看任务对象、协议和工作目录隔离有没有立住。只有这些基本骨架都没塌,最后才值得继续追权限、sandbox、approval、审计和恢复这些正式生产约束。读到这里,真正带走的不该只是 learn-claude-code 这个仓库名,而应该是一条更可靠的工程判断:开发型 harness 不是凭空长成复杂系统的,它被真实执行问题一层层逼出来的。

把这条生长线看顺后,下一步最值得读的不再是另一个最小 loop 样本,而是 Claude Code 这种完整系统实现。它把 QueryEnginetool / command / skillAppState、权限决策链、任务对象和 Bridge 都拉成了正式子系统,适合用来看开发型 Agent 怎样从教学骨架长成能进真实代码库的 runtime。第九章不在这里继续展开,真正的源码级拆解放到开源项目专题里的 anthropics/claude-code,专门讲它的主链路、关键代码模式和自研迁移路径。