别吹 Demo 了:读完 Pi 的 Harness v2 和 PR
别吹 Demo 了:读完 Pi 的 Harness v2 和 PR #8172,聊聊写 Agent 的那些烂坑
现在的 Agent 圈子很浮躁。
大家喜欢看炫酷的控制台动效,喜欢接几十个工具,喜欢等下一个更强的大模型。
但只要你把 Agent 放进真实的工程环境跑几天,就会发现现实很残酷:
- 跑了十分钟的任务,网络一抖或者内存一爆,进程挂了,会话直接报废。
- 执行一个
npm test,吐出三万行日志,上下文当场被撑爆,模型开始胡言乱语。 - 为了让用户实时插话,框架往会话中间塞了一条消息,底层的 KV Cache 全废了,Token 费用翻了五倍,响应慢得像蜗牛。
- 搞多 Agent 并发,多个任务抢着写同一个状态文件,数据直接写坏。
模型不是瓶颈,Demo 也没有意义。包裹在模型外面的 Harness(执行底盘)太烂,才是 Agent 落不了地的根本原因。
最近, Pi( pi.dev / earendil-works/pi) 发布了 harness-v2.md 规范,随后合入了 PR #8172。这是一份没有废话的工业级 Agent 底盘设计。
1. Pi 是谁?
在开源 Agent 项目里,Pi 很低调,但地位很硬。
- 核心作者与团队:Mario Zechner(GitHub:
@badlogic,著名开源游戏引擎 libGDX 的缔造者,典型的老派系统级程序员)与核心团队成员 David Brailovsky(@davidbrai)、Vegar Stikbakke(@vegarsti)。在设计文档中甚至明确写着: “如果设计不成立,停下来在 Discord 上找 Mario 商讨。” - PR #8172 作者:Adam Teale(
@adamteale)。这个仓库门槛极高,非协作者提 PR 会被 Bot 秒关,只有 Maintainer 明确给lgtm才能进入流程。代码质量把控极严。 - 项目定位: “Primitives, not features”(只做原语,不做死板特性)。它不搞花哨的 UI,只提供
@earendil-works/pi-agent-core(运行时)、pi-ai(模型层)和pi-tui(终端差分渲染)。像 OpenClaw 这类复杂的自主 Agent 框架,底层直接依赖 Pi 的 SDK。
2. 竞品锐评:市面上的 Harness 错在哪?
把 Pi 的设计和主流竞品放在一起看,很多流行框架的做法其实很业余。
锐评 LangChain / LangGraph:自找麻烦的“图状态机”
LangGraph 试图用有向图和 Generator 状态机去模拟每一步执行,搞出一堆复杂的类型转换和模板代码。一旦中途报错,状态机极难恢复。
Pi 的选择:Pi 早期也写过一套 Generator 状态机(
harness-v2-generator.md),后来直接废弃。Mario 的理由很简单: 直线的async/await最容易调试和理解。 不需要把代码切成碎块,只要把副作用边界划清就行。
锐评 DeepSeek Harness (dsh):粗暴丢数据的截断器
处理工具返回的超大文本时,DeepSeek 的 Harness(dsh)采用固定截断策略(保留头 4096 字符、尾 1024 字符,中间直接扔掉)。
PR #8172 的爆破:PR #8172 作者 Adam Teale 指出: dsh 的做法是永久丢失数据。 报错堆栈的关键信息通常就在中间,扔掉之后模型就彻底瞎了。PR #8172 提出了 Spill-copy-on-prune:裁剪 Context 的同时,把完整内容写入磁盘文件,并保留精确的字符偏移。模型不仅知道中间被裁了,还能用
grep去磁盘文件里把那几行找出来。
锐评 AutoGPT / CrewAI:没有容灾的纸糊玩具
这些项目擅长展示“多智能体开会”,但根本没有真正的持久化与崩溃恢复(Durability)。任务跑了 30 步,第 29 步崩溃,整局重来。
3. Agent 开发者天天踩的五个坑,Pi 怎么解?
坑 1:中途插消息,KV Cache 当场报废
现象: Agent 正在跑工具,用户发了一条修正指令(Steer)。很多框架直接把这条消息插到对话历史里。 大模型的 Prompt Cache 依赖严格的前缀匹配。你在中间动一个字,后面的缓存全部失效。费用暴涨,延迟翻倍。
Pi 的解法:Append-Only Context(只增上下文) Pi 确立了一条铁律: 上下文只能在尾部增长。
- 运行中产生的配置变动、用户插话、延迟写入,先暂存到内存队列。
- 等当前回合(Turn)跑完,到达 Checkpoint(检查点) 时,统一追加到上下文末尾。
- 缓存前缀永远不变,KV Cache 命中率永远最高。
坑 2:进程挂了,留下半截死状态
现象: 模型发起了两个工具调用,第一个执行完了,进程崩溃。重启后,对话历史里有调用指令却没有工具返回,下一次请求直接报 API 格式错误。
Pi 的解法:WAL 意图日志与预分配 ID Pi 借用了数据库系统的 Write-Ahead Logging 思想:
- 执行工具前:先写一条
step_attempt意图记录到磁盘,里面包含预先分配好的结果 ID(resultEntryId)。 - 执行完成后:用这个 ID 写入真实的工具结果。
interface StepAttemptRecord extends RecordBase {
type: "step_attempt";
runId: string;
step: "assistant" | "compaction" | "branch_summary";
attempt: number; // 重试次数写在磁盘上,进程重启也不会无限重试
resultEntryId: string; // 预先指定的 Entry ID
}
系统重启时,恢复程序扫描日志。如果发现有意图记录但没有对应的结果,就能精准知道崩溃发生在哪个位置:能重试的自动重试,不能重试的自动写入一条 "interrupted" 占位, 绝不会出现残缺的孤儿状态。
坑 3:超大输出撑爆上下文,盲目截断导致模型幻觉
现象: 执行一个命令吐出 50KB 日志,塞进上下文直接超限;如果直接把中间砍掉,模型看不到错误信息,开始胡乱猜测。
Pi 的解法(PR #8172 三级无损管道):
- > 50K 字符:内容全部写入磁盘文件(
~/.pi/agent/cache/tool-spill/),上下文里只给简短预览和文件路径。 - > 8K 字符:
- 完整内容写入磁盘。
- 上下文保留头 4096 字符 + 尾 1024 字符。
- 在字符串索引 0 处写入标记
[pruned — full at /path...],并标明被裁剪的精确范围[start, end)。
- 防死循环:模型调用读取工具去读溢出文件时,Pruner 自动放行,绝不二次截断。
实战测试数据(基于 GLM-5.3 和 DeepSeek-V4-Flash 的 19 次真实测试):
- 未命中缓存的 Prefill Token 减少 72% ~ 88%。
- 每轮请求上下文占用减少 26% ~ 35%。
- 幻觉测试通过率 100%(0/9 幻觉),模型能通过精确偏移用
grep/sed从磁盘精准读回丢失的日志。
坑 4:多任务并发,状态文件被写烂
现象: 多 Agent 系统让多个子任务并发修改同一个状态文件,在 Node.js 或 Python 异步环境下极易产生写覆盖和文件损坏。
Pi 的解法:Lanes(泳道)与不可变树 Pi 把会话结构分成了两层:
会话树(只增、共享、无状态): a ─── b ─── c ─── d
└─── e ─── f
泳道(各自独立): main ──> 指向 d
slack_thread_1 ──> 指向 f
- 底层树(Tree):所有节点只增不减,所有泳道共享,只读不改。
- 泳道(Lane):相当于 Git 分支。每个 Lane 只有自己的指针和操作队列,两个 Lane 并行工作完全不需要加锁互斥。
- 单写者(Single Writer):整个会话只有一个写入器,所有并发写操作通过单调递增的序列号排队追加。
坑 5:异步 Check-Then-Act 竞态
现象:
代码判断“当前任务已结束”,准备关闭会话;就在这一瞬间,用户发来了 abort 或新消息。时序交错,导致中止失败或消息丢失。
Pi 的解法:Lane Mutation Line(泳道原子排队线) Pi 用一条极简的 Promise 链解决了所有竞态问题:
let tail: Promise<unknown> = Promise.resolve();
function mutateLane<T>(job: () => Promise<T>): Promise<T> {
const result = tail.then(job);
tail = result.then(() => undefined, () => undefined);
return result;
}
- 状态检查和落盘写入,必须打包成一个同步 Job 放进队列执行。
- 网络请求、模型调用、工具执行在队列外面跑,跑完了再排队写状态。
- 任何两个并发操作,在时间线上永远只有
[先 A 后 B]或[先 B 后 A],没有第三种可能。
4. 确定性测试:线上代码怎么写,测试就怎么跑
很多框架的单测都是在 Mock 数据,一到线上就出 Bug。
Pi 定义了一个纯粹的副作用接口 Effects( fx):
interface Effects {
appendEntry(...): Promise<Entry>;
appendRecord(...): Promise<T>;
streamAssistant(...): Promise<SettledAssistantMessage>;
executeTool(...): Promise<{ result: AgentToolResult; isError: boolean }>;
}
Agent 的所有逻辑,只能通过 fx 操作外部世界。
这带来了一个巨大的优势:
- 生产环境(
drive: "automatic"):fx直通系统,异步全速运行。 - 测试环境(
drive: "manual"):每一个操作都会在fx边界停住。测试脚本可以像单步调试器一样,一步一步推着 Agent 走——可以在执行工具前强制断电,也可以在两个工具调用中间强行插入中断信号。
测试测的,就是线上跑的原版代码。
5. 总结
做 Agent,写 Prompt 只是第一步。
当系统进入真实工程,决定生死的是这几件事:
- 保住 KV Cache:不要随意插队改前缀。
- 做好 WAL 与崩溃恢复:把每一次动作先记下来再执行。
- 无损管理上下文:大输出落盘,保留偏移,给模型找回数据的能力。
- 管好并发与时序:单写者、只增树、排队写。
把这些脏活累活做干净,Agent 才能从玩具变成工具。