一个 Agent Loop 可以简单到只有十几行,但成熟的 Coding Agent 却会逐渐长出 Session、Context、Event、Hook、Policy,甚至 Durable Harness。为了理解这些复杂度究竟从哪里来,本文以 Pi 为源码样本,实现了一个最小 Agent Harness——Mini Pi,并进一步接入真实 LLM,观察当模型开始驱动真实工具、影响真实环境之后,Harness 需要解决哪些问题。
Mini Pi:https://github.com/grad0x/mini-pi
Pi:https://github.com/earendil-works/pi本文是「企业研发 AI 自动化」系列第 38 篇,欢迎关注和交流。
一个 Coding Agent 最核心的代码,可以简单到什么程度?
如果暂时拿掉 UI、模型适配和各种产品能力,剩下的核心逻辑并不复杂:把当前上下文和一组工具交给模型;如果模型选择调用工具,就执行工具,把结果再交回模型;如果模型不再调用工具,这次执行结束。
简化以后,大概只有这样一个循环:
while (turn < maxTurns) {
const assistant = await model.generate({
messages,
tools,
})
if (!assistant.toolCalls?.length) {
return assistant
}
for (const call of assistant.toolCalls) {
const result = await executeTool(call)
messages.push(result)
}
}
但成熟 Agent 的实现显然远不止这十几行代码。复杂度究竟从哪里来?
Pi 是一个很适合观察这个问题的开源样本:核心实现足够克制,同时已经覆盖较完整的 Agent Runtime 能力,并继续向更耐久、更可恢复的 Harness 演进。为了区分哪些复杂度只是具体项目的实现选择,哪些又是 Agent 真正进入工程环境后很难绕开的边界,这次实践进一步实现了一个更小的 Agent Harness——Mini Pi。
它不是 Pi 的复刻,也不是另一个 Claude Code。更准确地说,Mini Pi 是一个验证装置:从最简单的 Agent Loop 开始,逐步加入真实 Tool、持久化、上下文、事件和策略控制,再把预设行为的测试模型替换成真实 LLM,观察这些结构为什么会出现。
最小的 Agent Loop 可以归纳成一个很简单的过程:模型负责决策,Tool 把决策作用到外部环境,环境返回的结果再成为模型下一轮决策的输入。
这里有一个重要边界:Loop 本身并不负责决定任务应该怎么完成。
例如让 Agent 修一个 Bug,Loop 并不知道应该读哪个文件,也不知道哪一行代码有问题。模型可能选择先读源码,也可能搜索仓库,还可能先执行测试。Loop 负责的是把这些决定真正执行出来,再把结果反馈给模型。
如果只是做一个很短的 Demo,到这里已经足够。但真实任务很快会带来另外一些问题:一个任务已经执行了十几轮,进程退出以后,前面的执行经历怎么办?历史越来越长,下一轮模型还应该看到全部内容吗?模型要求执行一条 Shell Command,Runtime 是否应该直接照做?UI、Trace 和日志都想知道 Agent 执行到了哪里,这些逻辑难道都写进 Loop?
这些问题不属于模型推理本身,却直接决定一个 Agent 能不能从 Demo 走向真正的软件系统。
Mini Pi 的第一版只有 Agent、Model、Tool 和 Loop,已经能够完成最基本的 User → Model → Tool Call → Tool Result → Model → Final。真正往下实现以后,最先遇到的问题是:历史怎么留下来。
Agent 运行期间,会在内存里持续维护消息和运行状态。但只要任务需要暂停、重启,或者下一次继续,单纯的内存状态就不够了。因此需要另一层能力,持久保存用户输入、模型回复、Tool Call 和 Tool Result。
Mini Pi 把它叫作 Session。这里的 Session 可以理解为 Agent 能够长期保留和恢复的执行历史,而不只是普通聊天产品里的“会话”。于是第一次出现了两个生命周期不同的东西:AgentState 管当前正在运行的状态,Session 保存需要长期留下来的执行轨迹。Pi 的 Session 也采用类似思路,并通过 JSONL 以及 id、parentId 等信息为历史恢复和树形结构提供基础。
但保存全部历史之后,新的问题马上出现:下一次调用模型时,是不是把这些内容全部重新发送一次就行?
短对话当然可以。可 Agent 一旦持续运行几十轮,历史会越来越长,而且不是所有信息都和当前决策同等重要。因此,“系统拥有的信息”和“模型这一轮应该看到的信息”需要分开。
Mini Pi 中形成了这样一层关系:
不用太在意这些具体名字,它们真正表达的是一个更重要的判断:Runtime 可以保留完整状态,但每一次模型调用只需要拿到当前合适的执行视图。 这也是后续做上下文裁剪、压缩、缓存,以及针对不同模型调整输入策略的基础。
当一次任务开始包含多轮模型调用和多次 Tool 执行以后,外部系统也需要知道 Runtime 正在发生什么:当前第几轮,模型请求是否开始,哪个 Tool 正在执行,Tool 成功还是失败,整个 Run 是否已经结束。如果 UI、日志、Telemetry 都直接侵入 AgentLoop,Loop 很快就会和大量外部模块耦合。
因此 Mini Pi 增加了 Event。Runtime 只负责发出生命周期事件,谁关心,谁就订阅。TraceCollector 就是其中一个订阅者:它记录执行过程,但 AgentLoop 并不知道 TraceCollector 的存在。
仅仅“看见”执行过程又不够。如果模型准备执行一个危险操作,系统真正需要解决的是另一个问题:这件事到底能不能发生?
因此执行链上还需要一个能够介入 Runtime 的位置。Mini Pi 增加了 Hook,并把 Policy 放在 Tool 产生真实副作用之前:
到这里,几个边界已经比较清楚:Tool 提供能力,Policy 控制这些能力如何被使用;Event 观察执行,Hook 介入执行。
这些结构并不是为了把架构拆得更漂亮,它们分别对应了已经出现的工程问题。
这些能力组合起来以后,Mini Pi v0.1 大致形成了下面这套结构:
User
│
▼
SessionRuntime
/ \
▼ ▼
Agent Session
│ │
AgentState JSONL
│
▼
AgentContext
│
▼
ContextCompiler
│
▼
ModelInput
│
▼
ModelClient
│
▼
AgentLoop
│
Tool Call
│
Validation / Hook
│
▼
Tool
│
▼
Environment
Agent / Loop ──→ Events ──→ Trace
完整代码已经开源:https://github.com/grad0x/mini-pi
相比最终这张架构图,更值得关注的是它实际长出来的顺序:先有 Agent 和 Loop,再接入真实 Tool;真实执行带来了持久化问题,于是有了 Session;完整历史和模型输入开始分离,于是有了 Context;执行过程需要被观察,于是出现 Run、Turn 和 Event;真实副作用需要被控制,于是又增加 Hook 和 Policy。最后,才把测试模型替换成真实 Model Provider。
每增加一层,都对应一个已经出现的问题。
第一次验证这套 Harness 时,并没有马上接入真实 LLM,原因是需要先控制变量。如果 Harness 和模型行为同时都不确定,一旦任务执行失败,很难判断究竟是哪一层出了问题。
因此第一阶段使用了一个固定行为的测试模型。它没有真正的推理能力,只会按照预先写好的规则返回 Tool Call。测试场景也故意做得很小:
任务是找出这个 Bug,完成最小修改,并执行测试。测试模型按照四轮运行:第一轮读取代码和测试,第二轮修改 calculator.js,第三轮执行 node --test,第四轮返回最终结果。
这一阶段真正要验证的并不是“模型会不会修 Bug”,而是 Harness 自身:Tool 是否真的修改环境,Context 是否正确进入下一轮模型输入,Session 是否能保存并恢复完整历史,Event 生命周期是否闭合,Policy 是否能在 Tool 真正执行之前拦截动作。
这些机制全部跑通以后,才进入第二阶段:把预先写死决策的测试模型替换成真实 LLM。
Mini Pi Core 并不直接依赖某一家模型 API,内部只有一个很小的接口:
接入真实模型时,只新增了一个 OpenAI-compatible Adapter,把 Mini Pi 内部的 Message、Tool 和 Tool Result 转成模型 API 能够理解的格式,再把响应转换回来,随后接入 DeepSeek。
这里首先验证了一个基础设计:Model 可以替换,Harness 不需要跟着重写。 Agent、Loop、Context、Session、Tool、Event、Hook 和 Policy 全部继续沿用。
真正发生变化的是另一件事:下一步做什么,不再由测试代码提前写死。
第一次运行真实模型时,模型并没有直接读取 calculator.js,而是先执行:
确认 Workspace 中有哪些文件,随后读取 calculator.js、calculator.test.js 和 package.json,再修改代码、执行测试并返回结果。
第一次真实 Run 一共产生 5 个 Turn、5 次 Model Call、6 次 Tool Call、34 个 Trace Event 和 12 条 Session Message。随后又连续执行了三次,有意思的是,四次运行的高层策略其实相当稳定:都先探索 Workspace,再读取实现和测试,修改文件,运行测试,最后结束。
这并不符合一种常见的简单想象——概率模型每次都会走出完全不同的路线。至少在这个极小、目标明确的任务里,模型表现出了相当稳定的高层策略。
但其中一次仍然出现了局部差异:其他几次把 ls 和 find 合在一条 Shell Command 中,而这一次模型在同一个 Turn 里生成了两个独立的 run_command Tool Call。最终得到的信息差不多,动作表达却不同。
这反而更清楚地暴露了真实模型和测试模型之间的本质区别。
固定行为的测试模型里,执行策略直接写在代码中:还没读文件就 read,已经读完就 write,已经写完就 test。Runtime 可以提前知道路线。
接入真实 LLM 以后,这个前提消失了。即使连续四次的高层策略都很接近,Harness 仍然不能假定下一步一定是 read_file,不能假定模型只返回一个 Tool Call,也不能假定 Tool 参数一定正确。
执行路径是否稳定是一回事,Runtime 能不能把它提前写死,是另一回事。
当决策权从程序转移给模型以后,Harness 必须为一个无法提前完全确定的执行过程提供稳定语义:Tool 参数仍然要校验,Tool 产生的真实副作用仍然要受控制,Runtime 仍然要可观察,每一步结果仍然要正确进入下一轮 Context,完整执行轨迹仍然要保存和恢复。
从这个意义上说,接入真实模型以后,Mini Pi 才第一次真正开始面对 Harness 需要管理的对象。
四次真实运行还有一个稳定现象:模型每一次都先使用 Shell 探索 Workspace。
Mini Pi 当前没有专门提供 list_files、search_files 等能力,但有一个范围很宽的 run_command,而 Shell 本身就可以组合出很多行为。
四次实验不足以证明“缺少 list_files 导致模型使用 Shell”,但至少提出了一个值得继续验证的问题:Tool Surface 的设计,会不会影响模型倾向于选择什么样的行动路径?
这里的 Tool Surface,可以简单理解为 Agent 实际能够调用的能力集合。一个 run_command 几乎可以做任何事,而 read_file、grep、git_status 这些 Tool 只暴露一小块明确能力。因此 Tool Design 不只是模型调用 API 的问题,它同时定义了 Agent 可以用什么粒度、什么权限范围去作用于真实环境。
Mini Pi 还实现了一个简单 Policy:
它用来验证在 Shell 真正执行以前,Harness 能否通过 Hook 阻止一次动作。答案是可以。
但真实模型开始主动组合 Shell Command 后,也很容易看出这种机制离真正的安全隔离还有多远。Shell 可以写成 ls && find ...,危险操作同样可以有大量等价表达,因此 exact-match Policy 只能回答:按照当前规则,这个动作应该被允许吗?
Sandbox,也就是隔离真实执行环境,解决的是另一个问题:无论模型尝试做什么,它在技术上真正能够影响的范围是什么?Policy 和 Sandbox 解决的是两层不同的问题。
还有一个很小但很重要的设计。
Agent 修改完代码以后,自己执行 node --test。测试通过,于是模型回复:
The test passes now.
如果只看 Agent 自己的判断,这次任务已经完成。但 Mini Pi 的实验程序没有直接接受这个结论,Agent Run 结束以后,Application 又独立执行了一遍测试。第二次仍然 PASS,整个实验才被判定成功。
也就是说,这里存在两层判断:
模型认为任务完成,并不意味着系统必须接受任务已经完成。
在一个简单 Demo 里,这只是额外执行一次测试;如果 Agent 真正进入研发交付链路,编译、自动化测试、页面验证、发布门禁都可以成为独立于模型判断之外的验收条件。
真实 Run 最终留下了 12 条 Session Message 和 34 个 Trace Event。数量不同,是因为两者回答的问题不同:Session 主要保存“Agent 做过什么”,Trace 更接近记录“Agent 是怎么运行的”。
Mini Pi v0.1 也在这里暴露出一个实现边界:Session Entry 的 timestamp 是消息真正持久化到 JSONL 时生成的,因此不能准确代表模型调用和 Tool 执行实际发生的时间。所以,能够恢复历史和能够还原执行过程,是两种不同能力。
真实模型跑通以后,Mini Pi 已经可以完成一个完整的 Coding Agent 任务,但距离 Durable Harness——能够在中断和故障后可靠恢复执行状态的 Harness——仍然有明显距离。
Mini Pi 当前一次 Run 的顺序大致是:
假设 Tool 已经修改了真实文件,Agent 也认为任务成功,但就在 Session 完整写入以前,进程崩溃。此时会出现一个麻烦的状态:真实环境已经改变,系统的持久化记录却可能不完整。
所以,Run completed 并不等于 Durable committed。
再往下追问,问题会变成:Tool 执行以前,要不要先记录“准备产生这次副作用”?如果系统恢复以后发现某次执行只有开始记录、没有结果,怎么判断动作究竟有没有发生?这个动作能不能安全重试?如果同一个动作不能执行两次,又该怎么办?
这已经不是把 JSONL 换成数据库就能解决的问题,它开始进入 Durable Execution:如何让一次长时间运行、会产生真实副作用的工作,在进程中断后仍然能够判断执行到了哪里,并安全恢复。
Pi Harness v2 进一步引入 Operation——比普通 Run 更强调持久化和恢复语义的工作单元——并围绕执行意图、结果和恢复机制继续演进,正是在处理这一类问题。
从 Mini Pi 再回看这些设计,可以得到一个更稳定的判断:Harness 的复杂度并不是从 Agent Loop 本身产生的,而是从模型开始持续作用于真实环境以后产生的。
Agent 工程早期关注的更多是 Model、Tool Calling 和 Agent Loop,先解决模型能不能行动。当这一层逐渐可用以后,工程压力会继续移动到 Context、State、Session、Observability、Policy、Verification 和 Recovery,也就是一次模型驱动的执行过程能不能被可靠管理。
再继续往上,问题还会进一步扩展。Agent 从单机、单用户工具变成长期运行的服务以后,会开始需要 Server 和 Protocol;从一个 Agent 变成多个 Agent 并发工作,会需要调度、隔离和协作;从个人工具进入企业环境,又会出现多用户、多租户、身份、权限、审计、资产管理、评测、成本和运营治理。到了这一层,讨论的已经不只是 Agent Runtime,而越来越接近一套 Agent Platform。
这并不是一个要求所有 Agent 产品依次通过的成熟度模型,更像工程问题重心不断上移:当模型已经能够完成越来越真实、越来越长期的任务以后,系统最难的问题会逐渐从“模型能不能做”,转向“这次执行能不能被可靠管理”,再进一步走向“这些 Agent 能不能被规模化地运行和治理”。
Mini Pi 只是一个很小的验证性实现,但从 Pi 的源码结构,到 Mini Pi 的最小重构,再到真实 LLM 下的执行实验,至少可以看到一个比较清楚的方向:
Loop 解决的是 Agent 如何继续行动;Harness 解决的是一次无法提前写死、又能够影响真实环境的执行,如何被稳定地运行、观察、约束、验证,并在必要时恢复。
如果只看核心代码,一个 Agent 仍然可以只是一个 while 循环。真正困难的部分,往往发生在循环之外。
《企业研发 AI 自动化》是我持续记录 AI 进入真实研发流程后的实践系列。
它关注的不只是 AI Coding 工具本身,而是从需求输入、任务表示、Agent 执行到自动化验证的完整链路:AI 如何在真实工程系统里稳定运行,并逐渐形成可复用的研发自动化能力。
欢迎关注和交流~