流式输出要流的是步骤,不是字符
一句话总结
智能体需要流式输出的不只是令牌(token)。“现在在哪个节点”、“新确定了什么”、“结束后的完整状态”,分别以不同的模式输出,挑选的标准也不同。
为什么需要它
接入了一个整理会议纪要的智能体,用户反映“好像卡住了”。实际上运行得很好。这件事要花 12 秒左右,只是这段时间里屏幕上什么都没有。
最先想到的是“把令牌一个字一个字流出来”。但这个智能体只在最后调用一次模型。有令牌可流的时刻,已经是快结束的时刻了。
需要的是别的东西。像“正在把会议纪要分成段落”、“找到了 4 条决定事项”这样,把步骤和中间结果流式输出。而这是图知道的,不是模型。
工作原理
按照 Streaming 文档的规定,一行 stream(입력, stream_mode=...)(占位符为输入)就行。在本实验镜像的 langgraph 0.2.60 中亲自测得的形态如下。
| 模式 | 一个事件的形态 | 会出现几个 |
|---|---|---|
values |
整个状态(字典) | 输入状态 1 个 + 每个超级步 1 个 |
updates |
{노드이름: 그 노드가 돌려준 갱신}(占位符依次为节点名称与该节点返回的更新) |
每个节点 1 个 |
debug |
{"type": "task"/"task_result", "step": 번호, "payload": {...}}(占位符为编号) |
每个节点 2 个 |
custom |
节点用 writer(...) 放入的内容原样 |
节点调用了几次就有几个 |
| 以列表给出时 | (모드이름, 값)(占位符依次为模式名称与值)元组 |
把所选模式的事件混合在一起 |
这里容易搞混的地方有两处。
第一,updates 是以节点为单位,而不是以超级步为单位。并排运行的两个节点即使在同一个超级步里,事件也是各自单独出现一个。而 values 是在超级步结束后出现一次,所以是并排的两个节点的结果合并之后再出现。
第二,debug 的 step 是超级步编号。并排运行的节点有同样的编号。所以想知道“现在是第几个超级步,这个超级步里有哪些节点一起在运行”,就用这个模式。
只收集更新,能重建出状态吗
能。不过必须了解 Reducer。
for event in app.stream(입력, stream_mode="updates"):
for node, update in event.items():
for key, value in update.items():
state[key] = value # ← 이어 붙이는 열쇠에서 틀린다
像 trace 这样的接续拼接的键,如果这样覆盖,就只会剩下最后一格。在图内部由 Reducer 做的事,在外部重建时必须自己来做。
但是,即使把 Reducer 模仿得很像,仍然不会完全相同。在本实验镜像中亲自测量,是这样。
그래프에 노드를 split → keypoints → actions → compose 순으로 더함
updates 로 받아 이어 붙인 trace : ['split', 'keypoints', 'actions', 'compose']
values 의 마지막 이벤트의 trace : ['split', 'actions', 'keypoints', 'compose']
并排运行的两个节点的位置互换了。因为 updates 是按节点添加到图中的顺序出现的,而 Reducer 是把同一个超级步的值按节点名称顺序合并。两者每次给出的答案都一样(重复多少次都不会波动),但彼此不同。
所以规则是这样——如果顺序有含义,就不要把用 updates 重建的结果当作最终版。用来绘制界面没问题,但要保存或比较的最终状态,要用 values 的最后一个事件(或 invoke 的答案)。
除此之外,实际工作中的选择通常这样分——界面需要累积绘制的内容(进度日志、部分列表)用 updates 接收,由前端合并;如果整体更新最终状态就行,就用 values 的最后一个事件。values 每次都会送来完整状态,所以传输量大——状态里有大的值的话,那个值会在每个超级步重新发送。
节点自己发出的片段
有些东西不想留在状态里,却想展示给人看。比如“正在处理第 3 段”这样的进度提示。放进状态的话,会被写进检查点,恢复时也会跟着回来。
custom 模式就是那个位置。节点接收 writer 参数,自己发出。
def compose(state, writer: StreamWriter):
writer({"stage": "compose", "points": len(state["points"])})
return {"summary": ...}
用 writer(...) 放入的内容不会留在状态里,只会送到监听的一方。所以适合进度提示、部分文本、调试提示。
流式输出的内容与 invoke 的答案是相同的
values 的最后一个事件与 invoke() 返回的内容相同。可以亲自对照确认,而且相同这一点很重要——这意味着没有理由运行两次:比如界面用流式输出绘制,记录另外用 invoke 留下。
在现场相遇的样子
第一,想在没有可流的时刻去流。在最后只调用一次模型的图里,只加令牌流式输出,前面的沉默依旧。必须把步骤流式输出。
第二,用 values 每一步都输出大的状态。状态里有原文,那份原文就会在每个超级步重新发送。只把界面需要的内容用 updates 或 custom 发送,会更好。
第三,把更新用覆盖的方式合并。就是上面的重建陷阱。足迹只剩一格。
第四,把进度提示放进状态。检查点会变大,恢复时旧的进度提示会复活。
实际工作中真正重要的事
- 先确定要展示什么,再选模式。不是先有模式。
updates以节点为单位,values以超级步为单位,debug直到超级步编号。- 只靠更新重建状态,就必须了解 Reducer。
- 不想留在状态里的,用
custom发出。
下一项实验要做什么
一步步扩展 /root/work/agstream/stream.py。把同一个图用 values、updates、debug、多个模式一起、custom 五种方式流式输出,亲手数出事件的数量和形态。只收集更新重建最后的状态,并确认接续拼接的键上哪里会出错。最后对照流式输出得到的最后状态与 invoke 的答案是否相同,并把什么时候展示什么整理成记录。