OpenProgram · 设计 · Runtime

嵌套 Agentic LLM 流式传输

完整产品设计(非 spike)。目标:在 Agent method 执行树里,对每次 llm() / runtime.exec(..., execution_kind="llm") 做按节点、按通道的实时 token 流,覆盖 ExecutionStrip 卡片、右侧详情、思考折叠、取消、重连、并行 LLM,以及 in-process / subprocess 两条路径。先定契约,再按阶段实现同一设计——与 context-manager 设计哲学一致。

权威草案 · 待用户拍板开放问题 对齐现有 chat_response / stream_event / tree_update 无产品代码 · 无 commit Mac 路径不可达 → 写于仓库 docs

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
前端 TreeStepnote = 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 行)

1.2 右侧详情面板

1.3 并行多 LLM

同一 RuntimeBlock 下多个 LLM 子节点可同时 running。每个 node_id 独立缓冲、独立 seq 空间;UI 各行各自生长,互不覆盖。

1.4 取消

用户 Stop / execution.cancel → 节点 status=cancelled;已生成的部分文本保留为可见输出(并落盘);缓冲清除;卡片 badge「已取消」,不再接收该 node 的 delta。

1.5 重连 / 刷新

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_deltatoken唯一高频路径
tree_update开/关节点、子树变化、~1.2s、强制 flush结构 + 终态 +(节流后)partial output 镜像
不新增 llm_open/llm_close—开闭由 DAG + tree_update 表达,避免双通道竞态

2.4 排序、合并、背压

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]
FE 缓冲生命周期:create 于首个针对该 node_id 的 llm_delta(或见到 running exec 节点时预创建空缓冲)→ append 于每个 delta → clear 于终态 tree_update / cancelled / session 卸载 / 节点从树消失。

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;
  }
}

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 清除规则

5. 持久化策略(设计内拍板)

决定(v1):WS 上的每个 token 不入事件日志;DAG LLM 节点对 text 做 ~250ms 节流 partial 写盘;thinking 节流写入 metadata.streaming_thinking,关节点时折叠进 metadata.blocks 并删除 streaming 字段。终态以 _close_model_call_node 为准。

6. 失败 / 取消 / 错误 UX

情况DAGWSUI
用户取消status=cancelled;output=已生成 partial停止 delta;tree_update 终态badge 已取消;保留 partial;清缓冲
provider 错误status=error;error 字段;optional partial outputtree_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。

模式检测(仅诊断)

8. UI 文案与行为(默认提案)

「prompt 是否始终钉在 note 左侧」见开放问题 Q1;实现前按本默认。

9. 验收标准(整功能)

9.1 手工

  1. agentic 函数内单次 llm():开节点 → 卡片逐字生长 → 关节点后与 raw_reply 一致,缓冲消失。
  2. thinking+text 双通道:思考可折叠,正文主位生长。
  3. 并行两个 llm(或循环连呼):两行独立生长,无串台。
  4. Stop:立即停 delta;partial 保留;状态 cancelled。
  5. 中途刷新:可见 partial;若仍 running,续上新 delta。
  6. subprocess 工具(如 gui_agent)与 in-process 路径 UX 无差别。
  7. 详情面板 Reply 与卡片一致。

9.2 建议自动化

10. 非目标(v1)

11. 实现阶段(同一完整设计的有序施工,非「先上最小再看」)

  1. 协议与后端发射:Runtime 在 _active_llm_node_id 上下文把 on_stream 转为 llm_delta;广播辅助函数;单测信封。
  2. Subprocess 桥:确认 queue 透传;加隔离模式日志;双路径集成测。
  3. 节流落盘:running 节点 partial output/thinking;与 close 竞态测试。
  4. 前端存储 + chat-stream:llmStreams 切片;reducer;清除规则;不污染 reply。
  5. TreeStep + 详情:选择规则;thinking fold;样式。
  6. 取消/重连/并行:按 §6 §1 收口;手工清单签收。
  7. 文档与检查脚本:本页升「已采纳」;必要时 check-*.mjs 锁 UI 契约。
任一阶段不得擅自缩小产品语义(例如「subprocess 以后再说」或「先不落盘」)——那会变成 spike。若要改语义,先改本设计再写代码。

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,便于评审对照;非正式投票。

交互演示 · 卡片生命周期

idle

模拟:open → llm_delta×N → tree_update close。缓冲生命周期见右侧日志。


    

两个 node 并行 append,键隔离:

中途取消:保留 partial,状态 cancelled,缓冲清除。

刷新后先画盘上 partial(示例「已落盘:你好,世界」),再继续 delta。

只有 result、error、retry_result 帧结束聊天回合的状态处理。嵌套 execution_stream 帧由流状态归约器处理,不清除父回合运行状态、不覆盖回复内容、不刷新分支元数据。恢复回执本身不触发下一次相同的恢复请求。owner_unavailable 回执结束本次恢复并保留已有内容;终态节点不重新进入同步中。后续新的流事件仍可重新请求恢复。