嵌套 Agentic LLM 流式传输
完整产品设计(非 spike)。目标:在 Agent method 执行树里,对每次 llm() / runtime.exec(..., execution_kind="llm") 做按节点、按通道的实时 token 流,覆盖 ExecutionStrip 卡片、右侧详情、思考折叠、取消、重连、并行 LLM,以及 in-process / subprocess 两条路径。先定契约,再按阶段实现同一设计——与 context-manager 设计哲学一致。
0. 现状与缺口
| 层 | 已有能力 | 缺口 |
|---|---|---|
| 顶层 chat 流 | chat_response.data.type=stream_event,内层 event.type ∈ {text,thinking,tool_*};FE 用 rAF 合并进 assistant reply | 与嵌套 LLM 节点无关 |
| 执行树 | tree_update ~1.2s 轮询;LLM 节点 node_type:"exec",开节点 status=running / 关节点写 output+raw_reply | 关节点前 output 为空 → TreeStep 只见 prompt note,看不到生长中的回复 |
| Runtime 流 | Runtime.on_stream 已转发 provider text/thinking delta | 嵌套路径未带 node_id 上浮到 WS;subprocess 仅经 queue 转发现有 envelope |
| 前端 TreeStep | note = params 摘要 + duration + → short(output|raw_reply);详情面板 Prompt(params._content) / LLM Reply(raw_reply) | 无 Map<nodeId, PartialStream>,无法在 running 时把 delta 画进卡片 |
_event_parsing.agent_event_to_envelope、_exec_dag._poll / live_progress、runtime/history._open_model_call_node、apps/web/lib/net/chat-stream.ts、execution-strip.tsx#TreeStep、process_runner.run_agentic_in_subprocess。1. 端到端产品行为
1.1 ExecutionStrip 卡片(TreeStep · LLM 行)
- 开节点:树出现 LLM 行,running;note 次要位仍可显示 prompt 摘要(见 §8 / §12)。
- tokens:主视觉是不断增长的回复预览(text channel);thinking 默认折叠为「思考中…」或末行摘要(与顶层 ThinkingStep 一致)。
- 关节点:状态 → completed/error/cancelled;note 收敛为
→ short(final)+ duration;流式缓冲清除,真相源切回树节点output/raw_reply。 - tree_update:仍驱动结构(子节点增删、状态翻转);流式文本不依赖 1.2s 轮询,由
llm_delta驱动。
1.2 右侧详情面板
- 选中 running LLM 节点:Prompt 仍读
params._content;Reply 区优先显示 FE 缓冲全文(可滚动),无缓冲时回退树字段。 - 完成后:Reply = 持久化
raw_reply/output;Thinking 若有则来自metadata.blocks。
1.3 并行多 LLM
同一 RuntimeBlock 下多个 LLM 子节点可同时 running。每个 node_id 独立缓冲、独立 seq 空间;UI 各行各自生长,互不覆盖。
1.4 取消
用户 Stop / execution.cancel → 节点 status=cancelled;已生成的部分文本保留为可见输出(并落盘);缓冲清除;卡片 badge「已取消」,不再接收该 node 的 delta。
1.5 重连 / 刷新
- 刷新后:从 SessionDB 读到 running LLM 节点上的节流 partial output(§5),卡片立即显示已有文本 + running。
- 若 run 仍在:WS 恢复后继续收新的
llm_delta/tree_update(不要求服务端重放历史 delta)。 - 若 worker 已死:streaming-resume 的 abort sweep 将节点标 aborted;UI 显示 partial + aborted。
1.6 In-process 与 Subprocess
对用户 UX 完全一致。差异只在后端桥接(§7)。检测模式仅用于诊断/日志,不分流前端逻辑。
2. 完整事件协议
所有事件仍包在现有顶层信封里:
{
"type": "chat_response",
"data": { /* 见下 */ }
}
2.1 新增 data.type = "llm_delta"
与 stream_event / tree_update 平级(嵌套 LLM 不是顶层 assistant bubble 的 text,故不用内层 event.type:"text",避免误写入 reply.content)。
// text channel
{
"type": "chat_response",
"data": {
"type": "llm_delta",
"session_id": "sess_7c2a",
"msg_id": "runtime_a1b2", // RuntimeBlock / live_progress 锚点(亦称 runtime_id)
"node_id": "call_9f3e", // DAG LLM Call.id(TreeStep 用 path/id 对齐)
"channel": "text", // "text" | "thinking"
"delta": "你好",
"seq": 12 // 可选;同 node_id+channel 内单调递增
}
}
// thinking channel
{
"type": "chat_response",
"data": {
"type": "llm_delta",
"session_id": "sess_7c2a",
"msg_id": "runtime_a1b2",
"node_id": "call_9f3e",
"channel": "thinking",
"delta": "先拆解需求…",
"seq": 3
}
}
2.2 相关既有事件(字段名保持不变)
// 开节点后不久 / 结构变化(轮询或强制 flush)
{
"type": "chat_response",
"data": {
"type": "tree_update",
"session_id": "sess_7c2a",
"msg_id": "runtime_a1b2",
"function": "gui_agent",
"tree": {
"path": "…/gui_agent",
"name": "gui_agent",
"status": "running",
"children": [{
"path": "call_9f3e",
"name": "gpt-5",
"node_type": "exec",
"status": "running",
"params": { "_content": "Summarize the page…" },
"output": "",
"raw_reply": ""
}]
}
}
}
// 关节点后的最终 tree_update(root/child status=completed|error|cancelled)
// child.output / raw_reply = 完整文本;FE 以此为真相源并清缓冲
2.3 生命周期事件(可选但本设计采纳)
| 事件 | 何时 | 作用 |
|---|---|---|
llm_delta | token | 唯一高频路径 |
tree_update | 开/关节点、子树变化、~1.2s、强制 flush | 结构 + 终态 +(节流后)partial output 镜像 |
不新增 llm_open/llm_close | — | 开闭由 DAG + tree_update 表达,避免双通道竞态 |
2.4 排序、合并、背压
- 排序:同一
(node_id, channel)上,有seq则按 seq 追加;缺 seq 则按到达序。乱序低 seq 丢弃(不重排历史)。 - 服务端 coalesce:同一节点 16–32ms 窗口内可合并多个 delta 再广播(字段仍是一个
llm_delta,delta 为拼接串);禁止跨 node / 跨 channel 合并。 - 前端合并:沿用顶层 rAF 策略——
Map<nodeId, PartialStream>写入立即发生,DOM/store stamp 每帧最多一次。 - 背压:WS 发送队列上限(建议 per-session 512 帧);超限时优先丢弃中间 text delta,保留最新快照帧(周期性发「全量 snapshot」型 llm_delta:delta=自上次 snapshot 以来的增量,或单独字段
snapshot——v1 采用「超限则跳过中间、保证最终 close 树完整」)。thinking 同策略但更激进可丢。 - 与 tree_update 关系:delta 永不写入
tree子段;partial 文本经 §5 节流写入节点后再被下一次 tree_update 带上。
3. 时序图
sequenceDiagram
participant AF as Agent method
participant RT as Runtime.exec(llm)
participant DAG as SessionStore/DAG
participant BR as Broadcast(WS)
participant FE as Web FE
AF->>RT: llm(prompt)
RT->>DAG: _open_model_call_node (running)
Note over BR,FE: tree_update(子节点出现,output="")
BR-->>FE: tree_update
FE->>FE: TreeStep running;建 PartialStream[nodeId]
loop tokens
RT->>BR: llm_delta(node_id, channel, delta, seq?)
BR-->>FE: llm_delta
FE->>FE: buffer.append;rAF 刷新 note/详情
end
RT->>DAG: _close_model_call_node(output, status)
Note over BR,FE: tree_update 终态(output=全文)
BR-->>FE: tree_update
FE->>FE: 用树文本覆盖;clear PartialStream[nodeId]
4. FE 状态架构
4.1 Store 形状(推荐)
// 挂在现有 Zustand session-store 旁,或同 store 新切片
type PartialStream = {
text: string;
thinking: string;
lastSeqText?: number;
lastSeqThinking?: number;
updatedAt: number;
};
llmStreamsBySession: {
[sessionId: string]: {
// key = DAG node id(与 tree child.path / Call.id 对齐)
[nodeId: string]: PartialStream;
}
}
- 不把高频 delta 写进 message 行上的
contextTree(避免整树 deep clone)。 - TreeStep / DetailPanel 通过
(sessionId, node.path|id)读缓冲;有缓冲且节点 running → 显示流式;否则 note/raw_reply 走树字段。 - 与顶层
pendingDeltas(assistant reply)隔离:嵌套 LLM 绝不写入 reply.content。
4.2 TreeStep 选择规则
| 条件 | note / 主预览 |
|---|---|
| running ∧ 有 text 缓冲 | 主:生长中的 text(short 尾或全文滚动);prompt 仅次要(§8) |
| running ∧ 无缓冲 | 保留今日行为:params 摘要;可显示「生成中…」 |
| 终态 ∧ 有 output/raw_reply | → short(output) + duration(现逻辑) |
| 终态 ∧ 无文本 | 「无文本输出」 |
| thinking | 独立折叠行或 Step 内 fold;running 示末行,done 示首行(对齐 ThinkingStep) |
4.3 清除规则
- 该 node 的 tree 状态 ∈ {completed, error, cancelled, interrupted, aborted}
- 或
clearSessionByMsgId/ session_loaded 换会话 - 或 RuntimeBlock 卸载且无 running 子节点
- 清除前若树尚无 final output,用缓冲回填一次(防丢最后几 token 的竞态)
5. 持久化策略(设计内拍板)
metadata.streaming_thinking,关节点时折叠进 metadata.blocks 并删除 streaming 字段。终态以 _close_model_call_node 为准。- 活着的真相:FE PartialStream(最低延迟)。
- 可恢复的真相:节点
output/raw_reply的节流快照 + 终态全文。 - 不为每个 delta 建子节点或 append-only token 表(非目标)。
- 与
operations/streaming-resume对齐:placeholder + last_update_at;worker 重启 abort sweep 同样适用于嵌套 LLM 节点。
6. 失败 / 取消 / 错误 UX
| 情况 | DAG | WS | UI |
|---|---|---|---|
| 用户取消 | status=cancelled;output=已生成 partial | 停止 delta;tree_update 终态 | badge 已取消;保留 partial;清缓冲 |
| provider 错误 | status=error;error 字段;optional partial output | tree_update;可另有 runtime error 信封 | 红错误 note;详情 Error 块;保留 partial 供调试 |
| 超时 | 同 error + reason=timeout | 同上 | 文案区分「超时」 |
| WS 闪断后重连 | partial 已在盘 | 新 delta 继续;不重放 | 先画盘上 partial,再接流 |
| worker 崩溃 | 中止清理 → aborted | 无 | partial + aborted;不可再流 |
| seq 空洞 | — | — | 忽略;终态树覆盖 |
7. 进程内与子进程
进程内
Runtime.on_stream → 包装为 llm_delta(填入当前 _active_llm_node_id)→ _broadcast_chat_response。
live_progress(..., on_event=None) 直接广播 tree_update。
Subprocess(完整路径必需)
子进程同样发 llm_delta envelope 到 mp.Queue;父进程 drain 线程 on_event(env) → 广播。与今日 placeholder/tree_update/goal_update 同桥。
run_agentic_in_subprocess 已具备 queue;本设计要求 llm_delta 走同一桥,禁止子进程直连 WS。
模式检测(仅诊断)
- 父:
process_runner中该execution_id是否有 liveProcess。 - 子:是否安装了
QueueTransport/ 子进程 event_queue。 - 日志字段
agentic_isolation: "inprocess"|"subprocess";前端不分支。
8. UI 文案与行为(默认提案)
- 卡片主位:流式中显示生长中的 reply(text);完成后
→摘要。 - 提示词
_content:默认仍作次要信息——running 且尚无 token 时显示短 prompt;有 token 后 prompt 让位给 reply,详情面板始终可看全文 prompt。 - 思考:默认折叠;标题「思考」;running 显示最新一行;点击展开全文(详情或 inline fold)。
- 不把嵌套 LLM 文本复制到外层 assistant 气泡。
9. 验收标准(整功能)
9.1 手工
- agentic 函数内单次
llm():开节点 → 卡片逐字生长 → 关节点后与 raw_reply 一致,缓冲消失。 - thinking+text 双通道:思考可折叠,正文主位生长。
- 并行两个 llm(或循环连呼):两行独立生长,无串台。
- Stop:立即停 delta;partial 保留;状态 cancelled。
- 中途刷新:可见 partial;若仍 running,续上新 delta。
- subprocess 工具(如 gui_agent)与 in-process 路径 UX 无差别。
- 详情面板 Reply 与卡片一致。
9.2 建议自动化
- 单测:envelope 形装(node_id/channel/seq);乱序 seq 丢弃;coalesce 不跨 node。
- 单测:subprocess queue 能转发 llm_delta(mock Process/Queue)。
- 组件:TreeStep running+PartialStream 快照;终态清缓冲。
- 契约:chat-stream reducer 遇 llm_delta 不污染 reply.content。
- 可选 e2e:假 provider 吐固定 delta 序列,断言 DOM 文本。
10. 非目标(v1)
- Token 级审计日志 / 可回放的 delta 录像。
- 改顶层 chat
stream_event.text语义或 TUI 全面对齐(TUI 可后续只读忽略 llm_delta)。 - 结构化输出的逐字段流式高亮。
- 多模态(图)delta。
- 跨 session 的 LLM 流订阅。
- 用 llm_delta 替代 tree_update。
11. 实现阶段(同一完整设计的有序施工,非「先上最小再看」)
- 协议与后端发射:Runtime 在
_active_llm_node_id上下文把 on_stream 转为 llm_delta;广播辅助函数;单测信封。 - Subprocess 桥:确认 queue 透传;加隔离模式日志;双路径集成测。
- 节流落盘:running 节点 partial output/thinking;与 close 竞态测试。
- 前端存储 + chat-stream:llmStreams 切片;reducer;清除规则;不污染 reply。
- TreeStep + 详情:选择规则;thinking fold;样式。
- 取消/重连/并行:按 §6 §1 收口;手工清单签收。
- 文档与检查脚本:本页升「已采纳」;必要时 check-*.mjs 锁 UI 契约。
12. 开放问题(需用户拍板 · 最多 4)
Q1 · 卡片 note 上 prompt 的权重?
A) 有 token 后 prompt 完全让位给 reply(默认) B) prompt 始终短钉在左侧、reply 在右侧 C) running 只显示 reply,prompt 仅详情可见
Q2 · Thinking 默认展开吗?
A) 默认折叠(默认) B) running 时自动展开,完成后折起 C) 跟随用户上次选择
Q3 · partial 落盘是否包含 thinking?
A) text+thinking 都 250ms 节流(默认) B) 只落 text,thinking 仅终态 blocks C) 完全不落 partial(仅终态;伤重连体验,不推荐)
Q4 · msg_id 锚点遇到「模型调用的 agentic 挂在 assistant reply」时?
A) 始终用 runtime 占位行 id(与今日 tree_update 对 display=runtime 一致)(默认) B) 统一用外层 assistant msg_id,靠 node_id 区分 C) 同时带 msg_id+runtime_id 两字段
选择会记在 localStorage 键 op.design.llmStream.decisions,便于评审对照;非正式投票。
交互演示 · 卡片生命周期
模拟:open → llm_delta×N → tree_update close。缓冲生命周期见右侧日志。
两个 node 并行 append,键隔离:
中途取消:保留 partial,状态 cancelled,缓冲清除。
刷新后先画盘上 partial(示例「已落盘:你好,世界」),再继续 delta。
只有 result、error、retry_result 帧结束聊天回合的状态处理。嵌套 execution_stream 帧由流状态归约器处理,不清除父回合运行状态、不覆盖回复内容、不刷新分支元数据。恢复回执本身不触发下一次相同的恢复请求。owner_unavailable 回执结束本次恢复并保留已有内容;终态节点不重新进入同步中。后续新的流事件仍可重新请求恢复。