Skip to content

智能体回答流式输出(LISTEN/NOTIFY) ​

实施状态:本设计已落地。下文“目标”和“为什么现在不是逐字输出”记录设计时背景;当前 Runtime 与开放 API 均支持 model.delta,瞬态增量不入库、不重放,持久化事件与最终完整答案仍是可靠性依据。

目标与非目标 ​

目标:让智能体回答在内部控制台逐字出现(含思维链 reasoning_content),而不是"转圈 → 整段出现"。

手段约束:使用 Postgres LISTEN/NOTIFY,不引入 Redis,不新增数据库迁移。

非目标:

  • 不改对外的最终答案契约(run.completed 仍携带完整 view(final))
  • 不削弱任何现有校验(工具调用合法性、空响应、64000 字上限、轮次/工具上限)
  • 不动 LangGraph 图结构
  • 公开 API 的逐字流式(见「按面呈现」)

为什么现在不是逐字输出 ​

模型网关其实已经支持流式(ModelGateway.invoke(..., emit=),目前只有 /model-gateway/stream 路由在用),但运行时链路断了三处:

断点位置现状
① 运行时拿不到 deltamodel_gateway/gateway.py GatewayChatModel只有 ainvoke(阻塞),没有流式方法
② 事件写入重agent_runtime/repository.py append每条事件 SELECT … FOR UPDATE 锁 run 行
③ SSE 靠轮询apps/api/runtime_routes.py固定 sleep(0.5) 查库

核心设计决策 ​

决策 A:delta 是瞬态的,不入库 ​

数据分三类,只有一类要持久:

数据性质去向
运行生命周期 / 工具进度必须可重放runtime_events(照旧)
最终答案必须持久runtime_runs.output,finish() 写入(照旧)
逐字增量(含思维链)纯实时观感NOTIFY 直传,不落一行

收益:0 次新增数据库写入、0 次新增行锁,不需要合并缓冲、不需要新事件类型、不需要客户端分片重组。

决策 B:NOTIFY 就是消息队列 ​

生产拓扑 worker 与 API 是独立进程,纯内存不通用。NOTIFY 天然跨进程、事务性、零新基础设施。

决策 C:ReAct 循环必须按轮流式 ​

流式发生在 _model 节点内部,节点照旧返回完整 state,图语义不变。但只有最后一轮(无工具调用的轮次)的文字才是答案(engine.py 中 output if not reply.tool_calls else "")。

客户端渲染不变式:

事件动作
model.started{round:n}重置该轮缓冲
model.delta{round:n}追加
model.completed{round:n, tool_calls:k}k>0 → 该轮是"过程";k==0 → 该轮是"答案"

只转发 content delta;tool_calls 分片(残缺 JSON 参数)不转发。

决策 D:保证边界 ​

答案是有保证的(持久 + 终态事件携带完整内容);逐字增量是尽力而为的(瞬态,可丢)。

客户端断线重连、通知丢失、API 进程重启,最坏情况退化成今天的行为(转圈 → 整段出现),而非一致性错误。


数据流 ​

worker 进程
  engine._model
    ├─ content / reasoning delta ─→ pg_notify(chan, {"k":"delta","round":n,"text":…,"reasoning":…})
    └─ 轮结束 ─→ emit("model.completed") ─→ _event 入库                    ← 照旧
  运行结束 ────→ finish(output=完整答案) ─→ runtime_runs 入库               ← 照旧

api 进程(每进程一条专用 LISTEN 连接,可监听多通道)
  收到 "delta"   → 直接 yield(不碰数据库,不动游标)
  收到 "durable" → 从 cursor 读 runtime_events 并 yield(重放语义不变)
  15s 安全网      → 兜底轮询(防漏通知)

两种通知载荷,靠 k 字段区分:

形状含义客户端
{"k":"delta","round":n,"text":"…","reasoning":"…"}瞬态增量渲染,不动游标
{"k":"durable","seq":n,"type":"…"}持久事件指针从 cursor 读库渲染,推进游标

按面呈现(独白与思维链) ​

面端点过程轮(中间轮文字)思维链最终答案
内部调试(Trace、可观测性、会话工作台)内部 SSE /api/v1/runs/{id}/events转发,前端标记为"过程"流式转发流式
公开 API(集成方、EmbedChat、集成调试台)公开 SSE /openapi/v1/.../messages?stream=true转发,但客户端按下方规则丢弃不发送流式,且 run.completed 带完整 view(final)

两个面都逐字,也都只展示最后一轮答复。 差别只有思维链:它是内部调试信息,公开 API 不暴露。

判定"哪一轮是答案" ​

多轮 ReAct 下,某轮流式输出时无法预知它末尾会不会补 tool_calls(engine.py 里带工具调用的轮次其 output 会被丢弃)。所以两个面都按同一条规则判定:

答案是最后一轮 tool_calls == 0 的轮次。 以工具调用结束的轮次是模型的"过程话术",客户端应丢弃其文本。

公开 SSE 因此转发 model.delta{round, text},并转发带 round 与 tool_calls 的 model.completed,让客户端可以在该轮结束时丢弃它。run.completed 仍携带完整 view(final) 作为权威结果。

已知代价:某轮在结束前会被当作候选答案流式显示,若该轮最终以工具调用结束,客户端的文本会被清掉并换成真正的答案。这是在"要逐字"与"要零泄漏"之间必须二选一时选前者——结构性零泄漏只能靠缓冲整轮、从而丧失逐字效果。


具体改动 ​

#位置改动
1gateway.pyGatewayChatModel 增加流式调用方法:以 on_delta 回调驱动 gateway.invoke(..., emit=),返回完整 AIMessage;捕获 model_stream_not_supported 回退 ainvoke
2adapters.pystream_chat_http 把 reasoning_content 也 emit(当前只内部累积)
3engine.py_model 用流式方法 + on_delta 转发 content / reasoning;所有现有校验在完整回复上照跑
4worker 路径delta 直发 pg_notify(绕过 _event);其余事件照走 _event
5repository.py _event加 SELECT pg_notify(channel, pointer),所有持久事件自动带通知
6新增监听组件每进程一条专用连接(asyncpg add_listener)、自动重连、LISTEN/UNLISTEN 生命周期
7runtime_routes.py内部 SSE 循环改事件驱动 + 15s 安全网(wait_for + 兜底轮询)
8open_platform/routes.py公开 SSE 订阅同一通知,转发 model.delta(只带可见文本,不带思维链),并给 model.started/model.completed 补 round/tool_calls
9前端 ui.tsxuseRunStream:delta 累积到 ref、由 20ms 定时器定速放出,避免批处理把逐字压成大块跳跃;answerRound 选出最后一轮非工具调用轮
10前端消费方试聊、会话工作台、集成调试台、EmbedChat 均按同一规则只渲染最后一轮

不需要改的东西 ​

  • 数据库迁移:本方案不新增事件类型、不改 runtime_events 表结构
  • SSE 重连协议:after 游标 + Last-Event-ID 原样可用
  • 公开 API 契约:run.completed 仍带完整 view(final)
  • 最终答案:finish() 写入 runtime_runs.output 不变

风险与约束 ​

风险处理
delta 走 NOTIFY 载荷,8000 字节上限单块文本远小于此;限制块大小,不把整段答案塞进一条通知
流式是条件性的(非 chat / gateway_http → model_stream_not_supported)ainvoke 回退,功能只增不减
PgBouncer 事务池会破坏 LISTEN目前直连没问题;写入部署约束
工具执行期间无增量前端显示 tool.started,不伪装成打字
嵌入 Worker 模式 LISTEN 是纯开销特性开关 AGENT_STREAM_MODEL_DELTAS(默认开,可显式关闭)
delta 即时投递 vs _event 提交时才投递极小概率乱序;客户端按轮缓冲可容忍

分期与验收 ​

阶段内容验收
P1流式调用方法 + engine 消费 + 回退demo 后端可测(一次性发完整结果,验证接口通)
P1breasoning_content 也 emit真实 provider 下思维链逐字
P2delta 走 pg_notify(不入库)runtime_events 行数与今天完全一致
P3监听端 + SSE 端点改造延迟 ~50ms;断开监听仍正确收敛
P4前端按轮渲染三轮 ReAct 不把过程独白当答案

特性开关:AGENT_STREAM_MODEL_DELTAS,默认开;设置为 false 可一键回到非流式行为。

NexusDesk 采用 Apache License 2.0 许可