TT Lab
开始
学习 学习路径 课程

AI 智能体 — 是图不是模型

流式输出要流的是步骤,不是字符

在 TT Lab 中继续学习

一句话总结

智能体需要流式输出的不只是令牌(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 发送,会更好。

第三,把更新用覆盖的方式合并。就是上面的重建陷阱。足迹只剩一格。

第四,把进度提示放进状态。检查点会变大,恢复时旧的进度提示会复活。

实际工作中真正重要的事

下一项实验要做什么

一步步扩展 /root/work/agstream/stream.py。把同一个图用 values、updates、debug、多个模式一起、custom 五种方式流式输出,亲手数出事件的数量和形态。只收集更新重建最后的状态,并确认接续拼接的键上哪里会出错。最后对照流式输出得到的最后状态与 invoke 的答案是否相同,并把什么时候展示什么整理成记录。