创建日期:2026-09-09 | 最近更新:2026-09-09 本文所有「实验日志」均来自配套实验库
ioi/mini-agent(runtime/simple/complex/stream/traffic.mjs +logs/抓包),本机 DeepSeek Anthropic 兼容端点、deepseek-v4-flash[1M]实测;key 已打码。系列上篇:复杂 Agent。
把 Agent 的时序看穿:非流式 vs 流式,用真实日志剖析智能体的本质
一句话:智能体不是一个“跑起来的程序”,而是一连串被宿主编排的“请求 → 响应”回合;模型在每一个回合里只是“读一段静态输入、吐一段输出”的无状态单步函数。非流式与流式,改变的不是这个本质,而是这一次响应的内容在时间上怎么摊开给你。本文用
mini-agent的真实抓包日志,把两种时序模型画出来、拆开看,最后回答“智能体到底是什么”。
0. 材料:一次真实实验的两份日志
mini-agent 里 traffic.mjs 会把每次 HTTP 请求/返回落到 logs/。下面 simple-* 会话是非流式简单 Agent 跑「现在几点?请调用 get_current_time」的两回合,stream-* 会话是流式同一问句的原始 SSE。两条都是真实记录。
logs/simple-2026-09-08T15-35-35-083Z/
_meta.json base=https://api.deepseek.com/anthropic model=deepseek-v4-flash[1M]
req-01.json POST /v1/messages body.messages=[user "现在几点?请调用 get_current_time。"]
resp-01.json 200 · 1015ms · content块=[thinking, tool_use] · tool_use=get_current_time{} · stop_reason=tool_use
req-02.json POST /v1/messages body.messages[最后一条].content=[tool_result tool_use_id=call_00_… content="2026/9/8 23:35:35"]
resp-02.json 200 · 595ms · content块=[thinking, text] · text="现在是 2026年9月8日 23:35:35(上海时区)。" · stop_reason=end_turn
logs/stream-2026-09-08T15-23-16-643Z/raw-01.txt(首几帧)
event: message_start
data: {"type":"message_start","message":{…,"usage":{"input_tokens":90,…}}}
event: content_block_start
data: {"type":"content_block_start","content_block":{"type":"thinking",…}}
event: ping
data: {"type":"ping"}
event: content_block_delta
data: {"type":"content_block_delta","delta":{"type":"thinking_delta","thinking":"We"}}
…
后面两节的时序图,就是照着这两份日志画的。
1. 根本前提:模型是「无状态单步函数」
先钉死最关键的一个认知,后面所有讨论都从它来:
- 模型不会自己“跑”。你给它什么它回什么;它拿到的唯一输入是
messages(整段历史)+ 工具声明,输出的只是一段文本/块; - 它没有内部时钟——上一个回合“算到一半”的状态不会保留,全靠宿主把历史再发一遍;
- 所以“多步推理”“记忆”“会干活”这些智能感,全是从宿主的一次次往返里长出来的,不在模型内部。
把这一点当坐标:一个 Agent = 宿主的一个循环,每圈 = 一次“请求→响应”。看清了这个循环,非流式和流式就只是「每一圈里那次响应怎么回来」的区别。
2. 非流式的时序模型:回合粒度,整包等
Agent 的最小骨架(本系列篇 0的 while 循环),用真实日志的两回合画出来:
对应真实日志:
- 第 1 次请求发出去后,宿主阻塞等待了 1015ms,才收到包含
tool_use的整包 JSON; - 这段时间里模型在“想”,但宿主看不到任何中间产物——要么整包,要么没有;
- 宿主本地执行工具(毫秒级,几乎不耗时),把结果塞进历史再发第 2 次请求,又等 595ms,拿到最终文本。
非流式的特征一句话:等待是全有或全无——你付的总时长 = 模型这一回合的完整生成时间,且期间一片黑。
3. 流式的时序模型:同一回合被摊成一条时间线
把上面的同一次第 1 次请求加上 stream: true,观察时间轴(左边时间从上到下)——模型还没算完,内容就一段段到达:
本机另做的一次计时实测(同一问句,Anthropic 端点,真实):
Q: 用一句话(15字内)说明 HTTP 是什么,只输出答案。
非流式 total : 5262 ms 正文="HTTP是超文本传输协议。"
流式 首个字节 TTFB : 320 ms
流式 首个 data 帧 : 320 ms
流式 全部收完 total : 6423 ms 正文="HTTP是超文本传输协议。"
→ 打字机窗口(首帧→收完)≈ 6100 ms
读这张表的三个关键:
- 流式 TTFB(首字节)320ms ≪ 非流式的 5262ms——用户几乎立刻看到内容开始蹦;这就是打字机体验的来源。模型并没有算得更快,是**“先到的部分”先给用户看了**;
- 两者 total 是同量级的(5~6s)——流式不缩短总生成时间,它把同一段时长摊开成可感知的进度;
- 正文不是第一帧就出现:deepseek 是推理模型,thinking 先到、正文后到——所以在打字机 demo 里正文要等 thinking 块结束才开始显示(llm-format 流式篇 专门讲过)。
4. 对比:一张表看清两种时序模型
| 维度 | 非流式 | 流式(SSE) |
|---|---|---|
| 时间单位 | 回合粒度 | 回合内还要按帧细分 |
| 用户首感知 | 全部生成完(TTFT≈total) | 首字节即开始(TTFT 可小一个数量级) |
| 阻塞期间 | 一片黑,无中间产物 | 内容摊开抵达,可渲染/可中断 |
| 结束语义 | 整包 JSON:content + stop_reason | message_delta(stop_reason) → message_stop |
| 对 Agent 循环 | 收完即用 | 仍要收完整条才可用(见 §5) |
| 信息量 | 全有或全无 | thinking/text 分块先到(块语义与整包一致) |
5. 用时序回答“智能体的本质”
把 §1 的前提和上面两张时序图合起来,本质可以拆成三层:
① 记忆的本质 = 把历史再发一遍。
看 §2 日志:第二次请求的 messages 里躺着第一次的 assistant(tool_use) 和 user(tool_result)。Agent 没有“内部记忆”,它的记忆是宿主侧的一条不断变长的消息序列;所谓上下文窗口,就是这张每次都要整张寄给模型的“黑板”。时序上每回合都重寄全部 → 历史越长、单回合越贵(input_tokens 涨)。
② “干活”的本质 = 模型提议、宿主执行、结果回填。
tool_use 出现在响应的末尾(thinking 之后、宿主能看见它时已经是整包/已收完流),它只是“模型用文本写下的意图”;真正执行(查时间、写文件)的是宿主,结果以 tool_result 回填,让下一次请求能“看见”刚才干了什么。智能体的“迭代智能”,就是这层「看见结果再决定下一步」反复发生——执行权和决定权在时序上是分开的两段,这正是安全白名单能成立的物理基础。
③ 流式只是“呈现层”的优化,不改变协议语义。
这里有个新手最容易踩的坑:即使开了流式,Agent 也必须在 message_stop/[DONE] 之后,才把这一整条 assistant 消息(含可能出现的 tool_use)追加进历史。半截 delta 不能当最终消息用——thinking 可能被 max_tokens 截断、模型可能“说到一半改主意”、tool_use 的 input 要到块结束才完整。所以:
非流式与流式,改的是“响应的呈现时序”;不改的是“回合的语义”。 智能体依旧是宿主把消息一条条垒高、一次次全量寄出的循环;流式只让循环里每一次等待变得可感知、可打断、体验更顺。
6. 结论:想变强,别指望模型“跑起来”
把「智能体本质」压缩成一句工程判断:你在写的是一个消息序列的编排器,不是一个常驻进程。于是真正决定 Agent 上限的杠杆全在时序的“回合之间”,而不是单次响应内部:
| 杠杆 | 在时序里加在哪 | mini-agent 里的体现 |
|---|---|---|
| 更好的工具/回填 | 每个回合的结果质量 | complex.mjs:错误回填 is_error: true 让模型补救 |
| 记忆/上下文管理 | 发送前裁剪/摘要历史 | messages 越垒越高 → 后续可做摘要或向量检索 |
| 规划/多步拆解 | 在“请求前”先让模型产 plan | 多次往返本质上是同一个循环复用 |
| 体验(流式) | 单回合内部呈现 | stream.mjs 打字机、可中途中断 |
想从“能跑的 Agent”走向“聪明的 Agent”,方向是:让宿主在回合之间做对的事——更准的工具、更干净的历史、更可验证的落盘(以工具结果为准)。模型本身,始终只是那个被反复叩问的“无状态单步函数”。
动手
- 在
mini-agent跑node simple.mjs "现在几点?请调用 get_current_time。",去logs/simple-*/对照本文 §2 的两回合结构; - 跑
node stream.mjs "用一句话介绍 HTTP。",对比raw-01.txt与resp-01.json,数一数正文在哪个块才出现; - 自己写个计时脚本(仿本文),量你常用模型/长提示词下的“非流式 total vs 流式 TTFB”,体会差距。
自测
- 为什么说模型是“无状态单步函数”?Agent 的“记忆”实际存在哪?
- 非流式 Agent 的一回合要等多久才看得到结果?流式把这个等待变成了什么?
- 流式与总时长是缩短还是摊开?TTFB 说明了什么?
- 为什么 Agent 即使开了流式,也要等
message_stop才回填历史? - “工具执行权在宿主”这件事,在时序上为什么是安全白名单的根基?
参考:配套实验库 ioi/mini-agent(含抓包 logs/);协议流式细节见本站 llm-format 流式篇。