这一关做什么
前两关里,模型思考多久,你的终端就"死"多久。这一关把响应改成流式:文字一个一个蹦出来,工具调用一冒头就能看到。你会亲手解析 SSE(Server-Sent Events)数据流,理解"增量事件"这个模型——它是第 7 章事件驱动架构的物理基础。
动手前
接着用第 2 关的 playground。流式输出在 OpenAI 兼容协议里的变化只有三处:
- 请求体加
stream: true - 响应不再是完整 JSON,而是
data: {...}一行一行的文本流 - 增量里的字段叫
delta而不是message,工具调用参数会分片到达,需要自己拼接
完整代码
📦 配套代码:github.com/zhoujianbin/pi-mini-agent,
npm run lab03直接跑(已实测:SSE 流式输出 + 流式工具调用均正常)。
新建 lab03/stream.mjs(在第 2 关基础上改,工具部分原样保留):
// lab03/stream.mjs —— 流式版 Agent Loop
import { readFile, writeFile } from "node:fs/promises";
import { readdirSync } from "node:fs";
import path from "node:path";
const BASE_URL = process.env.OPENAI_COMPATIBLE_BASE_URL;
const API_KEY = process.env.OPENAI_COMPATIBLE_API_KEY;
const MODEL = process.env.MODEL ?? "deepseek-chat";
const WORKDIR = path.resolve("playground");
const toolImpls = {
list_files: () => readdirSync(WORKDIR).join("\n"),
read_file: ({ filename }) => readFile(path.join(WORKDIR, filename), "utf8"),
write_file: async ({ filename, content }) => {
await writeFile(path.join(WORKDIR, filename), content, "utf8");
return `已写入 ${filename}`;
},
};
const tools = [/* …与第 2 关完全相同,此处省略… */];
// ---- 流式调用:返回组装好的完整 message,过程中实时打印 ----
async function callModelStreaming(messages) {
const res = await fetch(`${BASE_URL}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${API_KEY}`,
},
body: JSON.stringify({ model: MODEL, messages, tools, stream: true }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
// 流式响应是一行行 "data: {json}",最后一行是 "data: [DONE]"
let content = "";
const toolCalls = []; // 按 index 归位:工具调用参数是分片流过来的
const decoder = new TextDecoder();
let buffer = "";
for await (const chunk of res.body) {
buffer += decoder.decode(chunk, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop(); // 最后一行可能不完整,留到下一轮
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const payload = line.slice(6).trim();
if (payload === "[DONE]") continue;
const event = JSON.parse(payload);
const delta = event.choices?.[0]?.delta;
if (!delta) continue;
// 文本增量:直接吐到终端,就是"打字机"效果
if (delta.content) {
content += delta.content;
process.stdout.write(delta.content);
}
// 工具调用增量:按 index 归位,参数逐段拼接
for (const tc of delta.tool_calls ?? []) {
const slot = (toolCalls[tc.index] ??= {
id: tc.id,
type: "function",
function: { name: tc.function?.name ?? "", arguments: "" },
});
if (tc.function?.name) slot.function.name = tc.function.name;
if (tc.function?.arguments) slot.function.arguments += tc.function.arguments;
if (tc.function?.arguments) {
// 参数每拼一段提示一次,肉眼可见"模型正在组织参数"
process.stdout.write(`\x1b[2m.\x1b[0m`);
}
}
}
}
if (content) process.stdout.write("\n");
const message = { role: "assistant", content: content || null };
if (toolCalls.length) message.tool_calls = toolCalls;
return message;
}
// ---- Agent Loop:和第 2 关一样,只是换成流式调用 ----
const messages = [
{ role: "system", content: "你是一个文件助手,通过工具帮用户管理文件。" },
{ role: "user", content: process.argv[2] ?? "看看目录里有什么文件" },
];
for (let turn = 1; turn <= 10; turn++) {
console.log(`\n—— 第 ${turn} 轮 ——`);
const msg = await callModelStreaming(messages);
messages.push(msg);
if (!msg.tool_calls?.length) break;
for (const call of msg.tool_calls) {
const args = JSON.parse(call.function.arguments);
console.log(`⚡ 工具 ${call.function.name}(${JSON.stringify(args)})`);
const result = await toolImpls[call.function.name](args);
messages.push({ role: "tool", tool_call_id: call.id, content: String(result) });
}
}
跑起来:
node lab03/stream.mjs "读出所有文件内容,写一份带标题的汇总到 report.txt"
逐段拆解
1. SSE 协议很朴素。 响应体就是文本流:data: {一小段JSON}\n\n 不断到来,最后以 data: [DONE] 收尾。没有魔法,不需要库。
2. delta 而不是 message。 非流式时你拿到完整的 message;流式时每一片只带变化量 delta。这正对应第 7 章里 message_start / message_update / message_end 的事件序列——Pi 把每个 delta 翻译成事件,TUI 订阅事件刷新界面。
3. 工具参数是分片的。 arguments 这个 JSON 字符串会切成好几段陆续到达(比如先 {"filena,再 me": "hel,再 lo.txt"})。必须按 index 归位逐段拼接,等流结束后才能 JSON.parse。这是流式工具调用最容易踩的坑。
4. 拼回完整 message。 循环记账需要完整消息,所以流式处理完后我们要把 content 和 tool_calls 重新组装成第 2 关那种完整 message。生产级实现(如 pi-ai)内部维护一个"增量聚合器",干的就是这件事。
常见坑
- 跨 chunk 的半行:一行 JSON 可能被 TCP 切成两半,所以要有
buffer暂存末尾不完整行 - 提前 JSON.parse:arguments 没拼完就解析必挂,要等整轮流结束
- content 为 null:本轮只调工具时 content 可能是 null,组装 message 时别写成空字符串(部分供应商对
""和null的处理不同)
闯关自测
- [ ] 能画出 SSE 流里一轮对话的事件顺序
- [ ] 能解释为什么 tool_calls 要带 index 归位
- [ ] 能说出 delta 模型与第 7 章事件类型的对应关系
对应理论章节
- 第 6 章:消息系统——partial message 如何长成完整消息
- 第 7 章:事件驱动——message_update 事件就是 delta 的封装