Subscription 管道:完整拆解

全局数据流

LLM API (DeepSeek/OpenAI/Anthropic)

│ SSE stream / WebSocket frames

@mariozechner/pi-ai library (transport层)

│ 解析 SSE → 生成标准化事件

pi-coding-agent library (session层)

│ 管理 messages 数组状态 → 发出 AgentEvent

session.subscribe(handler) ← 注册回调

│ AgentEvent { type, message, ... }

createEmbeddedPiSessionEventHandler() ← 事件调度器

├── message_start → handleMessageStart()
├── message_update → handleMessageUpdate() ← 每个 token
├── message_end → handleMessageEnd()
├── tool_execution_start → handleToolExecutionStart()
├── tool_execution_update → handleToolExecutionUpdate()
├── tool_execution_end → handleToolExecutionEnd()
├── compaction_start → handleCompactionStart()
├── compaction_end → handleCompactionEnd()
├── agent_start → handleAgentStart()
└── agent_end → handleAgentEnd()

│ handler 内部调用:
emitAgentEvent({ runId, stream, data })
│ ctx.params.onPartialReply(text)
│ ctx.params.onBlockReply(payload)
│ ctx.params.onToolResult(result)

infra/agent-events.tsnotifyListeners()

│ 全局事件总线

gateway/server-runtime-subscriptions.ts

│ onAgentEvent → createAgentEventHandler()

gateway/server-broadcast.tsbroadcastInternal()

for (client of connectedClients)
│ JSON.stringify(frame) → client.socket.send(frame)

WebUI / 其他 WebSocket 客户端

核心设计:pendingEventChain — 保序执行

// pi-embedded-subscribe.handlers.ts:23-73

export function createEmbeddedPiSessionEventHandler(ctx) {
let pendingEventChain: Promise<void> | null = null;

const scheduleEvent = (evt, handler, options?) => {
const run = () => handler();

// 情况 1:当前没有排队的事件 → 直接执行
if (!pendingEventChain) {
const result = run();
if (!isPromiseLike(result)) {
return; // 同步完成 → 结束
}
// 异步操作 → 变成链的起始点
pendingEventChain = result.finally(() => { pendingEventChain = null; });
return;
}

// 情况 2:有事件在排队 → 链到后面
pendingEventChain = pendingEventChain
.then(() => run())
.finally(() => { pendingEventChain = null; });
};
}

为什么需要保序?

如果不保序:
LLM 先返回 text_delta "你好"
LLM 紧接着返回 tool_call exec("ls")
如果两者并发处理 → 可能 exec 先跑完再显示 "你好" → 输出乱序

保序后:
pendingEventChain:
handleMessageUpdate("你好") // 先完成
handleToolExecutionStart() // 再执行
handleToolExecutionEnd() // 等工具完成
handleMessageUpdate("结果...") // 最后继续

但有一个例外tool_execution_end 用了 { detach: true }

case "tool_execution_end":
scheduleEvent(evt, () => handleToolExecutionEnd(ctx, evt), { detach: true });
// ^^^^^^^^^^^^^
// detach=true → 不阻塞后续事件
// 因为 tool_execution_end 里可能有 hook 回调(如 after_tool_call),
// 它们的结果不影响后续 LLM 响应的处理

事件类型详解

message_start — 助手开始说话

export function handleMessageStart(ctx, evt) {
// 重置累积状态
ctx.resetAssistantMessageState(ctx.state.assistantTexts.length);

// 通知 "typing..." 状态
ctx.params.onAssistantMessageStart?.();
}

触发频率:每轮 LLM 回复开始时一次。


message_update — 逐 token 流式处理(最核心!)

export function handleMessageUpdate(ctx, evt) {
const msg = evt.message;
// 过滤非 assistant 消息
if (msg.role !== "assistant") return;

// ========================
// 分支 1: 思考模式 (<thinking>)
// ========================
if (isThinkingEvent(evt)) {
// 累积 <thinking> 内容
// 如果 reasoningMode="stream" → 实时推送思考过程
ctx.emitReasoningStream(thinkingContent);
return;
}

// ========================
// 分支 2: 正常文本 (text_delta)
// ========================
const delta = assistantRecord.delta; // "你"
const content = assistantRecord.content; // "你好,我是" (累积)

// 检测 <think>...</think> 标签(某些模型的推理格式)
const chunk = resolveAssistantChunk(delta, content, ctx.state);

if (chunk.cleaned) {
// 有清理后的新文本要显示

// A) 检测指令 (如 {directive})
const directive = parseDirective(chunk.cleaned);
if (directive) {
ctx.handleDirective(directive);
return;
}

// B) 发送 "partial reply" 给调用者
// (auto-reply 用这个做 streaming 到 Telegram)
ctx.params.onPartialReply?.(chunk.cleaned, {
runId, assistantIndex, fullText
});

// C) 发 agent event → gateway → WebUI
emitAgentEvent({
runId: ctx.params.runId,
stream: "text",
data: { delta: chunk.cleaned, content: fullText }
});

// D) 检查是否构成一个完整的 "block"
// (一个段落结束 / 一个代码块结束)
if (shouldFlushBlock(ctx)) {
ctx.params.onBlockReply?.(buildBlockPayload());
}
}
}

触发频率:每个 token 一次。Claude 4 打字速度 ~100 tokens/s = 每秒 100 次。


message_end — 助手说完了

export function handleMessageEnd(ctx, evt) {
const msg = evt.message;

// 最终化文本
const finalText = coerceChatContentText(msg);

// 处理 "静默回复"
if (isSilentReplyText(finalText)) {
// LLM 回了 "HEARTBEAT_OK" → 不需要向用户发任何东西
ctx.markSilent();
return;
}

// 解析最终指令
const directives = parseReplyDirectives(finalText);
// 例如: {model: "gpt-4o"} 切换模型
// {tts: true} 语音合成
// {reaction: "👍"} 发表情

// 记录最终文本
ctx.state.assistantTexts.push(finalText);

// 发送最终 block reply
ctx.params.onBlockReply?.(buildFinalBlockPayload());

// 发 agent event
emitAgentEvent({
runId: ctx.params.runId,
stream: "text",
data: { done: true, content: finalText }
});
}

tool_execution_start — LLM 要调工具

export function handleToolExecutionStart(ctx, evt) {
const toolName = evt.toolName; // "exec"
const toolCallId = evt.toolCallId; // "call_abc123"
const args = evt.args; // { command: "npm test" }

// 1. 记录开始时间(用于后续统计耗时)
toolStartData.set(toolCallId, { startTime: Date.now(), args });

// 2. 构建 meta 信息(给 WebUI 显示的摘要)
// 例如: "exec: npm test"
// "read: src/index.ts"
const meta = buildToolCallMeta(toolName, args);
ctx.state.toolMetaById.set(toolCallId, { meta });

// 3. 发 agent event → WebUI 显示 "正在执行 exec..."
emitAgentEvent({
runId: ctx.params.runId,
stream: "item",
data: {
type: "tool_start",
name: toolName,
args: summarizeToolArgs(args),
meta,
}
});

// 注意:工具的实际执行不在这里!
// 工具执行发生在 pi-coding-agent 库内部:
// session.prompt() → LLM 返回 tool_call
// → 库自动查找 tool.execute 函数 → 调用
// → 产生 tool_execution_update 事件 (进度)
// → 产生 tool_execution_end 事件 (结果)
}

tool_execution_update — 工具执行中间状态

export function handleToolExecutionUpdate(ctx, evt) {
// 只有少数工具会发 update(如 exec 的实时 stdout 输出)

// 发 agent event → WebUI 显示工具的中间输出
emitAgentEvent({
runId: ctx.params.runId,
stream: "item",
data: {
type: "tool_progress",
name: evt.toolName,
partial: evt.partial, // 部分 stdout
}
});
}

tool_execution_end — 工具执行完毕

export async function handleToolExecutionEnd(ctx, evt) {
const toolName = evt.toolName;
const result = evt.result; // tool 的返回值 (stdout/stderr/exitCode...)
const isError = evt.isError;
const duration = Date.now() - startData.startTime;

// 1. 运行 after_tool_call hook
// → 让插件有机会审计/记录工具调用
await runAfterToolCallHook({ toolName, args, result, duration });

// 2. 发送 tool result 回调
// → auto-reply 用这个决定是否需要额外处理
ctx.params.onToolResult?.({
toolName, toolCallId, result, isError, duration
});

// 3. 发 agent event → WebUI 显示工具结果
emitAgentEvent({
runId: ctx.params.runId,
stream: "item",
data: {
type: "tool_end",
name: toolName,
result: summarizeResult(result),
isError,
duration,
}
});

// 工具结果发回 LLM 是自动的:
// pi-coding-agent 库在 tool.execute() 返回后,
// 会自动把 tool_result 放入 messages,然后
// 重新调用 LLM API → 产生新的 message_start/update/end
}

compaction_start / compaction_end — 上下文压缩

function handleCompactionStart(ctx) {
ctx.state.isCompacting = true;
emitAgentEvent({
runId: ctx.params.runId,
stream: "lifecycle",
data: { type: "compaction_start" }
});
}

function handleCompactionEnd(ctx, evt) {
ctx.state.isCompacting = false;
ctx.incrementCompactionCount();

// 通知等待 compaction 完成的代码
ctx.resolveCompactionRetry();

emitAgentEvent({
runId: ctx.params.runId,
stream: "lifecycle",
data: { type: "compaction_end", reason: evt.reason }
});
}

从 handler 到 WebUI 的完整链路

以”用户在 WebUI 上看到 AI 打字”为例:

DeepSeek APISSE: data: {"choices":[{"delta":{"content":"你"}}]}


pi-ai openai-completions transport
→ 解析 SSE → 更新 output.content = "你"
→ 推送事件到 eventStream


pi-coding-agent session
→ 更新 messages[last].content = "你"
→ 调用 subscribeCallback({ type: "message_update", message: {...} })


createEmbeddedPiSessionEventHandler
→ scheduleEvent("message_update", () => handleMessageUpdate(ctx, evt))


handleMessageUpdate()
→ 解析 delta = "你"
→ 清理 <think> 标签(如果有)
→ 调 ctx.params.onPartialReply("你", {...})
→ 调 emitAgentEvent({ runId, stream: "text", data: { delta: "你" } })


infra/agent-events.ts → notifyListeners()
→ 遍历所有注册的 listener → callback(enrichedEvent)


gateway/server-runtime-subscriptions.ts
→ onAgentEvent(createAgentEventHandler({...}))
→ agentEventHandler(event) → 识别 stream="text"
→ broadcast("agent", event, { dropIfSlow: true })


gateway/server-broadcast.ts → broadcastInternal("agent", event)
for (client of params.clients)
│ → 检查 scope (需要 READ_SCOPE)
│ → 检查 bufferedAmount < MAX_BUFFERED_BYTES
│ → JSON.stringify({ type: "event", event: "agent", payload: {...} })
│ → client.socket.send(frame)


WebSocket → 网络 → 浏览器 → WebUI React 组件
→ onMessage → dispatch({ type: "AGENT_TEXT_DELTA", delta: "你" })
UI re-render → 用户看到 "你" 出现在屏幕上

整条链路延迟

  • LLM SSE 到事件循环处理:~0ms(已在内存中)
  • handler + emitAgentEvent:~0.1ms
  • broadcast JSON.stringify + send:~0.5ms(取决于连接数)
  • 网络 → 浏览器渲染:~5-50ms
  • 总计:~5-50ms(这就是为什么你能感受到”实时打字”)

状态管理:EmbeddedPiSubscribeState

subscription 内部维护一组可变状态:

const state: EmbeddedPiSubscribeState = {
// === 累积的回复文本 ===
assistantTexts: [], // 最终完成的助手文本列表

// === 工具追踪 ===
toolMetas: [], // 所有执行过的工具 meta
toolMetaById: new Map(), // 当前进行中的工具 (by callId)
toolSummaryById: new Set(), // 已发送摘要的工具
itemActiveIds: new Set(), // 当前活跃的 item
itemStartedCount: 0, // 启动的 item 数量
itemCompletedCount: 0, // 完成的 item 数量
lastToolError: undefined, // 最近一次工具错误

// === 流式文本处理 ===
deltaBuffer: "", // 未完成的 delta 缓冲
blockBuffer: "", // 未完成的 block 缓冲
blockState: { // <think>/<final> 标签解析状态
thinking: false,
final: false,
inlineCode: {...} // 内联代码块追踪(防误解析标签)
},

// === reasoning ===
reasoningMode: "off", // off | on | stream
streamReasoning: false, // 是否实时推送思考过程
reasoningStreamOpen: false, // 是否打开了 reasoning 流

// === compaction 状态 ===
isCompacting: false,
compactionRetryPromise: null,

// === usage 统计 ===
usageAccumulator: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, total: 0 }
},

// === 生命周期 ===
unsubscribed: false,
};

返回给调用者的接口

subscribeEmbeddedPiSession() 返回一组控制句柄:

// attempt.ts:1753
const subscription = subscribeEmbeddedPiSession({
session: activeSession,
onPartialReply, // 每收到一个 token 调一次
onBlockReply, // 每完成一个段落/块调一次
onToolResult, // 每完成一次工具执行调一次
onReasoningStream, // 思考过程实时回调
onAgentEvent, // 所有 agent event 回调
...
});

// 返回值:
const {
assistantTexts, // () => string[] — 已完成的回复列表
toolMetas, // () => ToolMeta[] — 工具调用记录
unsubscribe, // () => void — 停止订阅
waitForCompactionRetry, // () => Promise — 等 compaction 完成
isCompactionInFlight, // () => boolean — 是否正在 compaction
getUsageTotals, // () => Usage — token 用量统计
getCompactionCount, // () => number — compaction 次数
} = subscription;

attempt 在 session.prompt() 完成后用这些句柄:

// prompt 完成后
await session.prompt(userMessage);

// 取出结果
const texts = subscription.assistantTexts; // LLM 最终说了什么
const usage = subscription.getUsageTotals(); // 花了多少 token
const tools = subscription.toolMetas; // 调了哪些工具

// 清理
subscription.unsubscribe();

完整的事件时序(一次带 tool call 的对话)

时间轴:

t=0ms 用户发 "帮我看看这个文件"
└─ session.prompt("帮我看看这个文件")
└─ HTTP POST → DeepSeek API

t=200ms DeepSeek 开始回复
├─ agent_start
└─ message_start
└─ handleMessageStart() → onAssistantMessageStart → typing...

t=210ms 收到 delta "好"
└─ message_update { delta: "好" }
└─ handleMessageUpdate() → emitAgentEvent → broadcast → WebUI 显示 "好"

t=220ms 收到 delta "的,"
└─ message_update { delta: "的," }
└─ ... → WebUI 显示 "好的,"

t=250ms 收到 delta "让我看看。"
└─ message_update { delta: "让我看看。" }
└─ ... → WebUI 显示 "好的,让我看看。"

t=260ms LLM 决定调用 read 工具
└─ tool_execution_start { toolName: "read", args: { path: "src/main.ts" } }
└─ handleToolExecutionStart()
→ emitAgentEvent("item", { type: "tool_start", name: "read" })
→ WebUI 显示 "📖 Reading src/main.ts..."

pi-coding-agent 自动执行 read tool:
→ readTool.execute("src/main.ts")
→ 读取文件内容

t=265ms read 工具完成
└─ tool_execution_end { toolName: "read", result: "import React from..." }
└─ handleToolExecutionEnd()
→ emitAgentEvent("item", { type: "tool_end", name: "read" })
→ WebUI 显示 "✅ Read src/main.ts (1.2KB)"
→ onToolResult({ toolName: "read", result: "..." })

pi-coding-agent 自动把 tool_result 放入 messages
→ 重新调 DeepSeek API

t=500ms DeepSeek 收到 tool result → 开始新回复
└─ message_start
└─ handleMessageStart()

t=510ms 收到 delta "这个文件是一个 React 组件..."
└─ message_update → ... → WebUI 流式显示

t=800ms LLM 回复完成
└─ message_end { content: "这个文件是一个 React 组件,它..." }
└─ handleMessageEnd()
→ ctx.state.assistantTexts.push("...")
→ emitAgentEvent("text", { done: true })
→ onBlockReply(finalPayload)

t=800ms agent_end
└─ handleAgentEnd()

t=800ms session.prompt() 的 Promise resolve
└─ attempt 收集结果 → 返回给 run.ts 主循环

关键设计总结

设计 为什么
pendingEventChain 保序 保证用户看到的输出顺序和 LLM 产生的一致
tool_execution_end 用 detach hook 回调不阻塞后续 LLM 响应处理
emitAgentEvent 全局总线 解耦 Agent 执行和 WebUI 广播
state 对象可变 高频更新(100次/秒),不能每次 immutable copy
dropIfSlow 背压 慢客户端不拖累快客户端
onPartialReply vs onBlockReply partial:逐 token,block:段落级 → 不同渠道用不同粒度