第 04 模块 · 1 节

JSON 模式

《Pi 生产级工程》04 程序化使用 · 本节时长 34 分钟

让 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 都带着完整消息。想拼出实时文本,得contentIndexdelta 自己组装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 模式真正跑一次,并提取结果:

  1. pi --mode json "List files" 2>/dev/null | jq -c 'select(.type == "message_end")'
  2. 观察输出的 message_end 行,找到 assistant 的最终文本内容
  3. 再用 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 不是快照」的事实刻进脑子。


小结

  1. pi --mode json 把会话事件以 JSONL 输出到 stdout
  2. 事件覆盖 agent / turn / message / tool / queue / compaction 全生命周期
  3. message_update 是 delta-only,要自己用 contentIndex + delta 组装
  4. message_end 才包含最终权威消息
  5. 配合 jq 可只提取你关心的事件类型

下一节,讲比 JSON 模式更完整的协议:RPC 集成。