NDJSON:Agent 开发离不开的流式数据格式
你盯着终端等 Agent 回一句完整答案时,真正先到的往往不是答案,而是一行又一行状态:开始思考、调用工具、写出片段、再抛出下一个事件。
做 Agent 相关项目时,我越来越频繁地碰到同一种输出:不是一个大 JSON,而是 stdout / 日志文件里,一行一个 JSON 对象。飞书 lark-cli event consume 吐消息是这样,Cursor ACP 的事件流是这样,很多 LLM API 的流式响应、数据集导出、结构化日志也是这样。
这种格式叫 NDJSON(Newline Delimited JSON),也常被写成 JSON Lines / jsonl。名字不花哨,但一旦你要做“边产生、边消费”的系统,它几乎躲不开。
这篇文章主要梳理四件事:它是什么、什么场景真的需要它、为什么 Agent 开发特别依赖它,以及几个用起来会舒服很多的技巧。
它到底是什么
先给一个人话版定义:
NDJSON = 每行一个完整的 JSON 值,行与行之间用换行分隔。
常见长这样:
{"ts":"2026-07-20T10:00:01+08:00","type":"message","text":"帮我查一下明天日程"}
{"ts":"2026-07-20T10:00:02+08:00","type":"tool_call","name":"calendar.search"}
{"ts":"2026-07-20T10:00:03+08:00","type":"tool_result","ok":true,"count":2}
{"ts":"2026-07-20T10:00:04+08:00","type":"final","text":"明天有两场会,上午 10 点和下午 3 点。"}
对比一下传统 JSON 数组:
[
{"ts":"2026-07-20T10:00:01+08:00","type":"message","text":"帮我查一下明天日程"},
{"ts":"2026-07-20T10:00:02+08:00","type":"tool_call","name":"calendar.search"}
]
差别看起来只是少了外层的 [] 和逗号,工程语义却完全不同:
| 点 | 普通 JSON 数组 | NDJSON |
|---|---|---|
| 何时可读 | 通常要等整段完整 | 每来一行就能立刻解析 |
| 追加写入 | 改数组尾部很麻烦 | 直接往文件末尾加一行 |
| 出错隔离 | 中间坏一块,整包可能废 | 坏一行,通常只丢这一行 |
| 内存压力 | 大文件容易整包进内存 | 天然适合按行流式读 |
读的时机差很多,用一张图会更直观:
左边是“写完才能读”,右边是“来一行处理一行”。所以它不是“另一种 JSON 语法”,而是一种面向流、面向追加、面向增量处理的封装约定。
MIME 类型常见写成 application/x-ndjson;文件扩展名常见 .ndjson / .jsonl。名字怎么叫不重要,关键是:一行一条记录,每行本身必须是合法 JSON。
什么场景需要它
不是所有 JSON 都该改成 NDJSON。下面这些场景,它才真正划算。
1. 生产者还在写,消费者已经要读
日志采集、事件总线、CLI 子进程 stdout、长连接推送,都是这种模式。
生产者一边跑一边往管道里写;消费者不能等任务结束再一次性 json.loads()。你需要的是:看到换行,就解析一条,立刻处理。
2. 数据集很大,但又想保持“结构化”
机器学习语料、对话日志、评测结果导出,经常是几十万到上亿条记录。整文件塞进一个 JSON 数组,打开都费劲;改成每行一条,就能用普通文本工具切分、抽样、并行处理。
3. 需要“追加友好”的持久化
任务状态、审计日志、Agent run 轨迹,往往是不断追加,而不是反复重写整文件。NDJSON 对追加极友好:打开文件,write(json.dumps(obj) + "\n"),完事。
4. 异构记录混在同一条流里
同一条流里可能先后出现 ready、message、tool_call、error、done。JSON 数组当然也能装,但 NDJSON 更适合“先到先处理”,并且不同类型可以靠 type / event 字段分流。
一句话判断:
如果你的数据是“一串陆续到达的记录”,而不是“一个必须一次成型的文档”,优先考虑 NDJSON。
为什么 Agent 开发离不开它
Agent 系统的核心特征,不是“最终吐一个字符串”,而是长时间运行 + 多阶段事件 + 可观测。
一次典型 Agent 执行,大概会经历这些阶段:
这些中间态如果塞进一个最终 JSON,会有几个现实问题:
- 交互体验差:用户要干等几十秒,才突然看到整段回复;
- 进度不可见:工具调用、报错、审批请求都来不及展示;
- 失败难定位:任务中途挂了,你手里可能什么结构化轨迹都没有;
- 桥接层难写:IM 机器人、Web UI、日志系统都想“边收边处理”,不想缓存到结束。
NDJSON 刚好卡住这个痛点:Agent 把生命周期拆成事件,一行一个;外层服务按行消费,按 type 路由。
我之前搭飞书助手时,lark-cli event consume 就是往 stdout 吐 NDJSON;Python 侧逐行读,过滤消息,再决定走本地规则还是丢给 Cursor ACP。那条链路能薄,很大程度是因为两边都接受了“事件流”这个约定,而不是“一次返回完整对象”。
Agent 开发里,NDJSON 通常承担三类角色:
- 入站事件流:IM / webhook / CLI 推过来的原始事件;
- 出站进度流:思考、工具调用、增量文本、最终答案;
- 审计轨迹:把一次 run 的关键事件落盘,方便回放和排障。
可以说,模型负责“想”,工具负责“做”,而 NDJSON 经常负责“把过程变成可消费的协议”。没有它,很多 Agent 外壳就只能退化成阻塞式 RPC:发请求,干等,拿结果。
几个神奇、但其实很实用的技巧
“神奇”这个词用得有点夸张。下面这些更像是用多了之后会反复用到的小招。
1. 用 type 做事件协议,而不是一个大而全 schema
不要指望一条流里所有行都长得一样。更稳的做法是:
{"type":"ready","session_id":"s_01"}
{"type":"text_delta","delta":"正在"}
{"type":"text_delta","delta":"查询"}
{"type":"final","text":"正在查询日程。"}
消费端先读 type,再按事件类型解析其余字段。这比强行统一成一个巨型对象灵活得多,也更接近真实 Agent 生命周期。
2. 增量文本用 delta,最终再用一条 final 兜底
流式输出时,很多人会反复发送“截至目前的完整文本”。流量会膨胀,客户端还要自己 diff。
更干净的约定是:
text_delta:只带新增片段;final:带完整定稿,给需要一次性落库、转发、摘要的下游用。
这样 UI 可以边拼边显示,存储层又不用自己猜“现在拼到哪了”。
3. 一行必须完整,禁止跨行 JSON
这是 NDJSON 的硬规矩,也是最容易踩的坑。
下面这种不合法:
{"type":"message","text":"第一行
第二行"}
如果文本里本身有换行,应该先在 JSON 字符串里转义成 \n,保证物理上仍然是一行:
{"type":"message","text":"第一行\n第二行"}
写的时候养成习惯:
import json
import sys
def emit(obj: dict) -> None:
sys.stdout.write(json.dumps(obj, ensure_ascii=False) + "\n")
sys.stdout.flush()
读的时候也按行处理,并跳过空行:
import json
import sys
for raw in sys.stdin:
line = raw.strip()
if not line:
continue
event = json.loads(line)
handle(event)
flush() 很关键。不 flush,对端可能一直看不到事件,表现得像“Agent 卡住了”。
4. 坏行隔离:一行炸了,别让整条流水线停摆
生产环境的事件流难免脏。某行缺字段、截断、混进了调试输出,都很常见。
可以做成“坏行记日志,好行继续走”:
import json
import logging
logger = logging.getLogger(__name__)
def iter_events(fp):
for lineno, raw in enumerate(fp, 1):
line = raw.strip()
if not line or line.startswith("#"):
continue
try:
yield json.loads(line)
except json.JSONDecodeError:
logger.warning("skip bad ndjson line=%s content=%r", lineno, line[:200])
这和 JSON 数组完全不同:数组中间坏一块,常常整包都解析失败;NDJSON 可以天然做行级容错。
5. 用普通 Unix 工具就能抽样、过滤、排障
这是我很喜欢它的一点。文件还在那儿时,你甚至不需要先写 Python:
# 看最后 20 条事件
tail -n 20 run.ndjson
# 只看工具调用
rg '"type":"tool_call"' run.ndjson
# 统计事件类型分布
rg -o '"type":"[^"]+"' run.ndjson | sort | uniq -c | sort -nr
# 抽 100 条做本地复现
head -n 100 run.ndjson > sample.ndjson
对 Agent 排障特别有用:一次失败的 run,留下一份 NDJSON 轨迹,往往比只留最终报错更值钱。
6. 和 SSE / JSONL API 别死磕名字,抓住“行协议”即可
你会看到一堆相近概念:
- NDJSON / JSON Lines /
.jsonl - HTTP SSE(
data: {...}\n\n) - 某些 LLM provider 的 stream chunk
它们表层语法不完全一样,但工程直觉是通的:把连续过程切成可增量消费的记录。
落地时建议在自己的桥接层做一次归一化:外部协议不管是 SSE 还是厂商私有 chunk,进到你的 Agent runtime 后,统一变成内部 NDJSON 事件。后面的 UI、日志、重放、评测都只认一种格式,省心很多。
7. 给每条事件补三个便宜但超值的字段
如果这是你自己定义的事件流,尽量尽早带上:
{"type":"tool_call","run_id":"r_42","seq":17,"ts":"2026-07-20T10:01:02+08:00","name":"shell"}
run_id:把同一次任务串起来seq:保证顺序,方便去重和断点续传ts:算耗时、画时间线
这三个字段看起来不起眼,等你要回放一次“为什么 Agent 在第三步跑偏了”时,会非常救命。
什么时候别用
也说清楚边界,免得 indirection 上瘾。
- 配置文件、接口契约、需要强 schema 校验的文档:还是普通 JSON / YAML 更合适。
- 必须保证原子写入的小对象:比如“当前唯一状态快照”,直接写一个 JSON 文件更直观。
- 记录之间有复杂嵌套引用,且必须整体校验:NDJSON 更擅长“记录流”,不擅长“单一文档内的复杂图结构”。
- 下游只会
JSON.parse整包、完全不懂流式:强行 NDJSON 只会制造对接成本。
格式选择没有道德洁癖。Agent 运行时用 NDJSON,最终把摘要写进数据库用普通 JSON,完全可以同时存在。
结语
NDJSON 本身没有什么理论光环,它解决的是一个很土的问题:系统一边产生结构化事件,另一边已经有人要读。
在 Agent 开发里,这个问题会放大。因为 Agent 的价值往往不在最后那句漂亮的回答,而在中途那些可见、可打断、可审计的过程。一行一个 JSON,看起来简陋,却刚好把“过程”变成了协议。
如果你正在给 Agent 接 IM、接 UI、接日志,或者准备把一次 run 的轨迹留下来复盘,不妨先别急着设计复杂消息总线。很多时候,stdout 上老老实实吐 NDJSON,已经够你走很远。
版权声明: 本文首发于 指尖魔法屋-NDJSON:Agent 开发离不开的流式数据格式(https://blog.thinkmoon.cn/post/1001-notes-ndjson-agent-streaming/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。