让 pi 把话「结构化」说出来
到现在为止,我们都是在终端里跟 pi 对话。生产级的下一个能力,是让 pi 的输出变成程序能读的数据。这样,别的小工具、别的脚本、别的前端,都能把 pi 当「引擎」来用。
最轻的入口,就是 JSON 模式:
pi --mode json "Your prompt"
它会把会话的所有事件作为 JSON 行(JSONL)输出到 stdout。适合把 pi 集成进其他工具或自定义界面。
md-tools 的「程序化」灵魂就在这一节:你的工具箱不该只在交互式终端里能用,还要能被脚本调用。**用 JSON 模式,md-tools 可以把一次 markdown 处理的结果以结构化事件流吐出来,供你的脚本 jq 处理、或喂给别的程序。** 这一节先把「JSON 模式会输出什么」吃透。输出长什么样
第一行是会话头,后面是事件流:
{"type":"session","version":3,"id":"uuid","timestamp":"...","cwd":"/path"}
{"type":"agent_start"}
{"type":"turn_start"}
{"type":"message_start","message":{"role":"assistant","content":[],...}}
{"type":"message_update","assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"Hello"}}
{"type":"message_end","message":{...}}
{"type":"turn_end","message":{...},"toolResults":[]}
{"type":"agent_end","messages":[...]}
每一行都是一个 JSON 对象,描述一次事件。
关键事件类型
JSON 模式暴露的事件覆盖 agent 的完整生命周期:
| 事件 | 含义 |
|---|---|
agent_start / agent_end |
agent 开始 / 结束处理 |
turn_start / turn_end |
一个回合(一次 LLM 响应 + 工具调用)开始 / 结束 |
message_start / message_update / message_end |
消息开始 / 流式更新 / 结束 |
tool_execution_start / update / end |
工具执行开始 / 进度 / 结束 |
queue_update |
待处理的 steering / follow-up 队列变化 |
compaction_start / end |
压缩开始 / 结束 |
最重要的一点:message_update 是 delta
这是 JSON 模式最易错、也最值得记住的点:
message_update是纯增量(delta-only)记录。它既省略累积的消息字段,也省略assistantMessageEvent.partial,好让流的大小保持线性。
所以你不能指望每条 message_update 都带着完整消息。想拼出实时文本,得用 contentIndex 和 delta 自己组装;message_end 才包含最终权威的消息。
{"type":"message_update","assistantMessageEvent":{"type":"text_start","contentIndex":0}}
{"type":"message_update","assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"Hello"}}
{"type":"message_update","assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":" world"}}
{"type":"message_update","assistantMessageEvent":{"type":"text_end","contentIndex":0,"content":"Hello world"}}
delta 类型还有:text_*、thinking_*、toolcall_* 几组,分别对应文本、思考、工具调用。
一个真实用法:jq 过滤
程序化使用最典型的场景,就是用管道 + jq 只挑出你要的事件。比如只要最终消息:
pi --mode json "List files" 2>/dev/null | jq -c 'select(.type == "message_end")'
2>/dev/null 丢掉 stderr,jq 只保留 message_end 行——这就是把一个 agent 的回答变成「可编程的数据」的最小范式。
落地练习:结构化提取一次回答
用 JSON 模式真正跑一次,并提取结果:
- 跑
pi --mode json "List files" 2>/dev/null | jq -c 'select(.type == "message_end")' - 观察输出的
message_end行,找到 assistant 的最终文本内容 - 再用 jq 只提取 assistant 的文本,比如
.message.content[] | select(.type=="text")
怎么判断做对了?——你能用 jq 从 JSONL 流里干净地抽出 assistant 的最终回答文本;并且理解为什么不能从
message_update里拿完整内容。
卡住了怎么办? 没有 jq?先装一个(brew install jq 或对应包管理器)。想看的字段不在?对照上面的事件表,找到你要的那个类型再过滤。
常见坑:把 message_update 当完整快照
最常见的坑,就是以为每条 message_update 都带着完整消息,于是直接取它的 message.content——结果拿到的是空或残缺内容。
JSON 模式为了流大小是线性的,故意只发 delta。 想要完整内容,要么组装 delta,要么等 message_end 拿权威版本。写消费端逻辑前,先把这个「delta 不是快照」的事实刻进脑子。
小结
pi --mode json把会话事件以 JSONL 输出到 stdout- 事件覆盖 agent / turn / message / tool / queue / compaction 全生命周期
message_update是 delta-only,要自己用 contentIndex + delta 组装message_end才包含最终权威消息- 配合
jq可只提取你关心的事件类型
下一节,讲比 JSON 模式更完整的协议:RPC 集成。