1. 从一次 Agent 失控的深夜调试说起
凌晨两点,我盯着终端里那个已经跑了四十分钟的 Pi Coding Agent 会话,它卡在同一个文件读取操作上反复重试,日志里只有一行行重复的 tool call 记录,没有任何中断信号,也没有任何恢复入口。那一刻我意识到一个问题:我们花了大量精力去调优 Agent 的推理能力、提示词结构、工具调用准确率,却几乎没人在工程层面认真对待过"Agent 跑起来之后怎么办"这件事。
这就是 Pi-Harness 要解决的问题。它不是又一个 Agent 框架,也不是又一个提示词编排工具,而是给 Pi Coding Agent 补上的一层工程控制层——让一个原本"黑盒运行、崩了就重来"的 Agent,变成可观测、可恢复、可编排的生产级组件。如果你正在用 Pi Coding Agent 做代码生成、自动化重构、批量文件处理,或者你正在把多个 Agent 串成流水线,那这套东西基本就是绕不开的基础设施。
我先把结论摆在这里:Pi-Harness 的核心价值不在于让 Agent 变聪明,而在于让 Agent 变得可控。可观测解决的是"我看不见它在干什么",可恢复解决的是"它崩了我只能从头再来",可编排解决的是"多个 Agent 之间怎么交接任务"。这三件事听起来朴素,但真正落地时会牵扯出大量细节——状态怎么持久化、工具调用怎么拦截、失败怎么分类、重试怎么设计、多 Agent 之间用什么协议通信。下面我会把这套东西从设计动机到实操细节完整拆一遍。
需要说明的是,Pi-Harness 目前并不是一个已经高度标准化的开源项目,更多是一种工程模式的抽象。我下面讲的内容,一部分来自实际项目中的落地经验,一部分是基于"一个合格 Agent 基础设施应该长什么样"的合理推演。你在参考时,重点看设计思路和取舍逻辑,具体实现可以按自己的技术栈调整。
2. 为什么裸跑 Pi Coding Agent 迟早会出问题
2.1 Agent 的"三无"状态:无日志、无状态、无边界
大多数人第一次用 Pi Coding Agent 的场景都差不多:写个脚本,把任务描述丢进去,让它自己调工具、读文件、写代码,最后拿结果。跑通一个 demo 很爽,但一旦任务变复杂,问题就集中爆发。
我把这些问题归纳成"三无":
- 无结构化日志:Agent 内部的思考过程、工具调用参数、返回结果、耗时,全都混在标准输出里。你想查"它为什么在第 7 步选了那个文件",只能靠翻屏。
- 无持久化状态:Agent 的运行状态全在内存里,进程一挂,上下文全丢。哪怕只是网络抖动导致一次工具调用失败,整个会话也可能直接终止。
- 无执行边界:Agent 能调哪些工具、能访问哪些目录、单次任务最多跑多久、最多花多少 token,全靠提示词"求"它遵守,没有硬性约束。
这三个问题在 demo 阶段不明显,因为任务短、失败成本低。但当你把 Agent 接入真实工程流程——比如让它批量重构一个仓库、自动修复 CI 失败、生成并提交代码——"三无"就会变成事故源头。
2.2 一个真实的反面案例
我之前做过一个实验:让 Pi Coding Agent 自动修复一批 lint 报错。任务描述很清晰,工具权限也给了。前 20 个文件处理得很顺,到第 21 个文件时,Agent 读取了一个超大文件,触发了上下文超限,然后它开始"幻觉式"地编造文件内容,继续往下改。因为没有任何边界约束,它一路改下去,最后提交了一个完全错误的 patch。
事后复盘,问题链条是这样的:
- 没有对单次工具返回内容做大小限制,导致上下文被撑爆;
- 没有对 Agent 的输出做校验,幻觉内容直接进入下一步;
- 没有 checkpoint,无法回滚到出错前的状态;
- 没有失败分类,超限这种"可预期失败"和真正的逻辑错误混在一起处理。
如果当时有 Pi-Harness 这层控制,第 1 步就能拦截,第 3 步能回滚,第 4 步能针对性重试。这就是控制层的意义——它不提升 Agent 的上限,但极大抬高了下限。
2.3 控制层应该承担的四类职责
在动手设计之前,我先把 Pi-Harness 的职责边界划清楚。它不碰模型推理,不碰提示词工程,只做四件事:
| 职责 | 解决的问题 | 典型手段 |
|---|---|---|
| 可观测 | 看不见运行过程 | 结构化事件流、trace、指标 |
| 可恢复 | 崩了只能重来 | checkpoint、状态快照、断点续跑 |
| 可编排 | 多 Agent 难协作 | 任务图、消息协议、交接契约 |
| 可约束 | 行为不可控 | 工具白名单、资源配额、输出校验 |
这四类职责里,可观测是基础,因为另外三件都依赖"你能看见发生了什么"。所以下面先从可观测讲起。
3. 可观测层:把 Agent 的黑盒拆成事件流
3.1 事件模型设计:一切皆事件
可观测的核心思路很简单:不要让 Agent 直接输出文本,而是让它输出结构化事件。每一次思考、每一次工具调用、每一次工具返回、每一次状态变更,都是一个独立事件,带时间戳、带类型、带 payload。
我设计的事件类型大致分这几类:
session.start/session.end:会话生命周期step.begin/step.end:单步推理的开始与结束tool.call/tool.result:工具调用与返回checkpoint.save/checkpoint.restore:状态快照error.raised/error.handled:错误抛出与处理handoff.request/handoff.accept:多 Agent 交接
每个事件统一结构,大致长这样:
{ "event_id": "evt_20240115_001", "session_id": "sess_abc123", "timestamp": 1705312800123, "type": "tool.call", "step": 7, "payload": { "tool": "read_file", "args": {"path": "src/main.py"}, "call_id": "call_xyz" }, "meta": { "agent_id": "pi-coder-01", "parent_event": "evt_20240115_000" } }为什么要这么设计?因为一旦事件结构化,你就能做三件原本很难的事:回放(按时间顺序重放整个会话)、查询("第 7 步到第 12 步之间调了哪些工具")、聚合(统计工具调用成功率、平均耗时)。这比翻日志高效太多。
3.2 埋点位置:在哪几个关键节点必须打点
事件模型定了,接下来是埋点。不是所有地方都值得打点,打多了噪音大,打少了信息不够。我的经验是,以下节点必须打:
- 每次 LLM 调用前后:记录输入 token 数、输出 token 数、耗时、模型名。这是成本核算的基础。
- 每次工具调用前后:记录工具名、参数、返回大小、耗时、成功与否。这是排查"卡在哪一步"的关键。
- 每次状态变更:比如从"规划中"进入"执行中",从"执行中"进入"等待工具返回"。
- 每次错误:错误类型、错误信息、当时的上下文摘要。
这里有个容易踩的坑:不要在事件里塞完整上下文。我一开始图省事,把每一步的完整 prompt 都写进事件,结果事件流体积爆炸,查询慢得没法用。正确做法是事件里只存引用(比如 context_id),完整内容单独存,需要时再关联。
3.3 从事件流到可视化:三个实用视图
事件流本身是原始数据,真正有用的是基于它构建的视图。我常用的有三个:
第一个是时间线视图。横轴是时间,纵轴是事件类型,一眼能看出 Agent 在哪个阶段停留最久。我有个任务原本以为卡在 LLM 推理,时间线一看,80% 时间花在一个网络工具调用上,问题瞬间定位。
第二个是调用树视图。因为事件带了 parent_event,可以还原出调用层级。一个 step 下面挂了几个 tool call,哪个 tool call 又触发了子任务,树状展开非常清晰。
第三个是成本视图。按 session、按 step 聚合 token 消耗,找出"最贵的步骤"。我优化过一个 Agent,把成本降了 40%,靠的就是成本视图发现某一步反复读取同一个大文件。
提示:事件流一定要落盘,不要只放内存。我习惯用 JSONL 格式按 session 分文件写,查询时用命令行工具过滤,简单可靠,不引入额外依赖。
4. 可恢复层:让 Agent 崩了能接着跑
4.1 Checkpoint 的粒度选择:太粗没用,太细太贵
可恢复的核心是 checkpoint——在关键节点保存 Agent 的完整状态,崩溃后从最近的 checkpoint 恢复。但 checkpoint 的粒度是个需要权衡的问题。
粒度太粗,比如只在会话开始时存一次,那恢复等于重来,没意义。粒度太细,每一步都存,存储和序列化开销会拖慢整个流程。我的经验值是:在"不可逆操作"之前必须存,在"高成本操作"之后建议存。
什么叫不可逆操作?比如写文件、提交代码、发送请求。这些操作一旦执行,回滚成本高,所以执行前必须 checkpoint。什么叫高成本操作?比如一次昂贵的 LLM 调用、一次大规模文件扫描。这些操作完成后存一次,避免恢复后重复付费。
具体到 Pi Coding Agent 的场景,我会在这些点打 checkpoint:
- 每个 step 开始前
- 每次工具调用前(尤其是写操作)
- 每次 LLM 调用返回后
- 每次多 Agent 交接前
4.2 状态序列化:什么该存,什么不该存
Checkpoint 的本质是状态序列化,难点在于"存什么"。Agent 的状态大致分三类:
- 对话历史:消息列表,必须存,否则恢复后 Agent 失忆。
- 工具调用记录:已调用的工具及结果,建议存,用于去重和上下文。
- 运行时状态:当前 step、重试计数、临时变量,必须存,否则恢复后逻辑错乱。
不该存的是那些可以重建的东西,比如工具的定义、提示词模板、模型配置。这些从配置文件读就行,存进 checkpoint 只会让快照臃肿。
我用的序列化格式是 JSON,简单通用。如果状态很大,可以上 MessagePack 或 Protobuf,但大多数场景 JSON 够用。存储位置我倾向本地文件系统,按 session_id 分目录,每个 checkpoint 一个文件,文件名带 step 编号,方便按序恢复。
checkpoints/ sess_abc123/ step_000.json step_007.json step_015.json latest -> step_015.json4.3 恢复策略:从哪个点恢复,怎么保证幂等
恢复不是简单地"读最近的 checkpoint 继续跑",还要考虑幂等性。因为崩溃可能发生在工具调用执行后、checkpoint 保存前,这时候恢复会导致工具被重复调用。
解决思路有两个:
一是工具调用去重。每次工具调用带唯一 call_id,执行前先查记录,如果这个 call_id 已经执行过,直接返回缓存结果。这要求工具调用记录也持久化。
二是把 checkpoint 放在工具调用之前。这样恢复后最多重复一次"准备调用"的动作,不会重复执行副作用。代价是可能重复一次 LLM 推理,但相比重复写文件,这个代价可以接受。
我实际用的是组合方案:写操作类工具用"前置 checkpoint + call_id 去重",读操作类工具用"后置 checkpoint",因为读操作幂等,重复执行无害。
4.4 失败分类:不是所有错误都值得重试
可恢复的另一个关键是失败分类。我见过太多人写重试逻辑,不管什么错误都无脑重试三次,结果把"参数错误"这种确定性失败也重试了,纯属浪费。
我的分类方式是这样的:
| 失败类型 | 特征 | 处理策略 |
|---|---|---|
| 瞬时失败 | 网络抖动、限流 | 指数退避重试 |
| 资源失败 | 上下文超限、配额耗尽 | 降级或分片后重试 |
| 逻辑失败 | 参数错误、工具不存在 | 不重试,上报 |
| 未知失败 | 无法归类 | 有限重试后上报 |
瞬时失败用指数退避,第一次等 1 秒,第二次 2 秒,第三次 4 秒,最多三次。资源失败要特殊处理,比如上下文超限,不能简单重试,得先裁剪上下文或换更小的模型。逻辑失败直接终止当前 step,把错误抛给上层决策。
这套分类逻辑我封装成一个classify_error函数,所有工具调用返回后先过一遍,再决定下一步。实测下来,无效重试减少了 70% 以上。
5. 可编排层:多 Agent 协作的交接契约
5.1 什么时候需要多 Agent,什么时候不需要
先说个反直觉的观点:大多数场景不需要多 Agent。很多人一上来就搞"规划 Agent + 执行 Agent + 审查 Agent"三件套,结果通信开销比任务本身还大。我的判断标准是:如果单个 Agent 在上下文窗口内能完成任务,就别拆。
真正需要多 Agent 的场景通常满足以下之一:
- 任务天然分阶段,且阶段间上下文差异大(比如"需求分析"和"代码实现"用的知识完全不同)
- 需要并行处理独立子任务(比如同时重构多个模块)
- 需要角色隔离(比如"生成"和"审查"必须用不同视角,避免自我确认偏差)
Pi-Harness 的编排层不是鼓励你拆 Agent,而是当你决定要拆时,提供一套可靠的交接机制。
5.2 交接契约:Agent 之间传什么
多 Agent 协作最容易出问题的地方是"交接"。A Agent 把任务交给 B Agent,传什么?传多了冗余,传少了 B 干不了活。
我设计的交接契约包含四部分:
- 任务描述:B 要做什么,用结构化格式,不是自然语言散文
- 上下文引用:B 需要的历史信息,用引用而非全量拷贝
- 输入产物:A 已经产出的中间结果,比如分析报告、文件列表
- 验收标准:B 完成后怎么算成功,避免扯皮
用 JSON 表达大致是这样:
{ "handoff_id": "ho_001", "from_agent": "planner", "to_agent": "coder", "task": { "type": "implement", "target": "src/utils/parser.py", "spec": "实现 parse_config 函数,支持 YAML 和 JSON" }, "context_refs": ["sess_abc123/step_005", "sess_abc123/step_008"], "artifacts": { "interface_spec": "artifacts/parser_interface.json" }, "acceptance": { "tests_pass": true, "lint_clean": true } }关键点是context_refs用引用而不是内联。这样 B Agent 按需拉取上下文,避免一次性塞爆它的窗口。
5.3 编排模式:串行、并行、还是图
交接契约定了,接下来是编排模式。我常用三种:
串行编排最简单,A 完成交给 B,B 完成交给 C。适合流水线式任务,比如"分析→实现→测试"。实现上就是一个队列,每个 Agent 处理完把结果入队给下一个。
并行编排适合独立子任务。比如把一个大仓库拆成多个模块,每个模块一个 Agent 并行处理。难点是结果合并,我通常让一个"汇总 Agent"负责收集和冲突解决。
图编排最灵活,用有向图描述 Agent 之间的依赖。比如"分析"完成后同时触发"实现"和"文档",两者都完成后触发"集成测试"。实现上可以用拓扑排序驱动,也可以用事件驱动。
我实际项目里用得最多的是串行加局部并行。全图编排听起来很美,但调试成本高,除非任务确实复杂,否则不值得。
5.4 交接失败的兜底:超时、拒绝、回滚
多 Agent 协作必须有兜底机制,因为 Agent 之间不像函数调用那样确定。我设计了三种兜底:
- 超时:B Agent 在规定时间内没响应,A 可以选择重发、换 Agent、或降级自己处理
- 拒绝:B Agent 判断任务超出能力,可以显式拒绝,附带原因,A 据此调整
- 回滚:交接后如果下游失败,可以回滚到交接前的 checkpoint,重新规划
这三种机制里,超时最常用,拒绝最容易被忽略但很重要——让 Agent 有"说不"的能力,比让它硬着头皮瞎干强得多。
6. 可约束层:给 Agent 装上护栏
6.1 工具白名单:默认拒绝,显式授权
Agent 能调什么工具,必须由控制层说了算,不能靠提示词约束。我的做法是默认拒绝,显式授权:每个 Agent 启动时带一个工具白名单,不在名单里的工具调用直接拦截,返回"权限不足"。
白名单的粒度可以细到"工具 + 参数范围"。比如允许write_file,但只允许写src/目录下的文件;允许run_command,但只允许执行白名单里的命令。这样即使 Agent 被提示词注入攻击,也突破不了硬边界。
6.2 资源配额:token、时间、调用次数
配额是另一道护栏。我给每个 session 设三个上限:
- token 上限:累计消耗超过阈值就终止,防止无限循环烧钱
- 时间上限:wall-clock 超过阈值就终止,防止卡死
- 调用次数上限:单个工具调用次数超过阈值就终止,防止死循环
这三个上限在每次事件产生时检查,超限就触发session.end事件,并保存最终 checkpoint。实测下来,这套配额机制拦住了我遇到的大部分"Agent 跑飞"事故。
6.3 输出校验:不让幻觉进入下一步
Agent 的输出不能直接信任,尤其是涉及结构化数据时。我在每个 step 结束后加一道校验:
- 如果输出应该是 JSON,就解析一遍,解析失败就重试
- 如果输出涉及文件路径,就检查路径是否在白名单内
- 如果输出涉及代码,就跑一遍语法检查
校验失败的处理和工具失败类似,分类后决定重试还是上报。这道校验拦住了我前面提到的"幻觉式改文件"问题——Agent 编造的文件内容过不了语法检查,直接被拦下。
7. 落地实操:把 Pi-Harness 接进现有流程
7.1 最小可用版本:先跑通可观测
如果你现在就想上手,我建议从可观测开始,别一上来就搞全套。最小可用版本只需要做一件事:把 Agent 的输出改成结构化事件,落盘成 JSONL。
具体步骤:
- 在 Agent 的每个关键节点插入事件发射代码
- 事件统一走一个
emit_event函数,负责加时间戳、写文件 - 写个简单的查询脚本,支持按 session_id、事件类型过滤
这一步做完,你至少能"看见"Agent 在干什么了。我见过太多团队跳过这步直接搞编排,结果出了问题连日志都查不明白。
7.2 增量接入:按需加 checkpoint 和配额
可观测跑稳后,加 checkpoint。不用一步到位,先在最容易出问题的工具调用前加,观察一段时间,再逐步扩展。
配额也是增量加。先加 token 上限,因为这是最直接的止损手段。跑一段时间,看看正常任务的 token 分布,再定合理阈值。我一开始把阈值定太低,正常任务都被误杀,后来根据实际分布调到 P95 才合适。
7.3 多 Agent 编排:从两个 Agent 开始
编排别贪多,先从两个 Agent 的串行交接开始。选一个明确的分界点,比如"分析"和"实现",把任务拆开,跑通交接契约。两个 Agent 跑顺了,再考虑加第三个。
我踩过的坑是:一上来就设计五个 Agent 的复杂图,结果交接协议没定清楚,Agent 之间互相甩锅,调试成本爆炸。两个 Agent 的简单场景反而能快速暴露协议设计问题。
8. 几个我踩过的坑和对应的解法
8.1 事件流写成了"日志流"
最开始我把事件设计得太像日志,每个事件就一行文本,结果查询时只能 grep,没法做结构化分析。后来改成 JSON 结构,才真正发挥出事件流的价值。教训是:事件不是日志,事件是可查询的数据。
8.2 Checkpoint 存了不该存的东西
有次我把完整的对话历史塞进每个 checkpoint,结果一个 session 的快照有几十 MB,恢复时序列化慢得要命。后来改成只存增量,完整历史单独存一份,checkpoint 只存引用和差异。快照体积降了两个数量级。
8.3 重试逻辑把确定性失败也重试了
前面提过,无脑重试是常见错误。我现在的做法是,所有重试必须经过classify_error,只有瞬时失败才重试。这个改动让无效重试几乎归零。
8.4 多 Agent 交接时上下文重复传递
A Agent 把完整上下文传给 B,B 又传给 C,上下文越滚越大。解法是交接只传引用,每个 Agent 按需拉取。这个改动让多 Agent 场景的 token 消耗降了 50% 以上。
8.5 配额阈值拍脑袋定
配额阈值不能拍脑袋,得基于实际数据。我的做法是先跑一周不设限,收集 token、时间、调用次数的分布,然后按 P95 或 P99 设阈值。这样既不会误杀正常任务,又能拦住异常。
9. 关于这套控制层,我个人的几点体会
Pi-Harness 这类控制层,本质上是在回答一个问题:当 Agent 从 demo 走向生产,我们需要补哪些工程债。我的体会是,这些债迟早要还,早还比晚还好。可观测让你在出问题时能定位,可恢复让你在崩溃时能续跑,可编排让你在任务复杂时能拆分,可约束让你在 Agent 失控时能兜底。这四件事没有一件是"锦上添花",全是"雪中送炭"。
另外一个体会是,控制层的设计要克制。我见过有人把控制层做得无比复杂,结果 Agent 本身的灵活性被压没了。好的控制层应该像操作系统,平时你感觉不到它,出问题时它兜得住。工具白名单、配额、校验这些机制,默认应该是宽松的,只在必要时收紧。
最后分享一个实用技巧:把控制层的事件流当成 Agent 的"黑匣子"。每次 Agent 出问题,先看事件流,按时间线还原整个过程,十有八九能定位到根因。我现在排查 Agent 问题,第一步永远是打开事件流,而不是去读提示词。这个习惯帮我省了大量时间。
如果你也在用 Pi Coding Agent 做实际项目,建议至少先把可观测这层搭起来。哪怕只是把关键节点的事件落盘,你对 Agent 行为的理解都会上一个台阶。剩下的可恢复、可编排、可约束,可以按项目复杂度逐步加。控制层这东西,不是一次设计到位的,是跟着实际踩的坑一点点长出来的。