40岁的中年程序员面试 AI Agent 开发:第二篇,从 SSE 流式到工具调用循环
选 Bun 不选 Node,不是图快,是图省事。
Bun 的 fetch 原生返回 ReadableStream,getReader() 直接拿数据,不需要 node-fetch、不需要 undici、不需要手动处理背压。Node 里做同样的事要绕一层,Bun 一行到底。
以下是完整引擎笔记,全部代码来自实际项目,无删减。
1. SSE 原生流式解析
const res = await fetch(url, { method: "POST", headers, body, signal });
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
const delta = parseOpenAIDelta(line);
if (delta?.content) onChunk(delta.content);
}
}
解析函数同样精简,没有 zod,没有类型守卫,够用就行:
export function parseOpenAIDelta(line: string) {
const trimmed = line.trim();
if (!trimmed.startsWith("data:")) return null;
const data = trimmed.slice(5).trim();
if (data === "[DONE]") return null;
try {
const obj = JSON.parse(data);
const delta = obj.choices?.[0]?.delta;
if (!delta) return null;
return { content: delta.content ?? "", toolCalls: delta.tool_calls ?? [] };
} catch {
return null;
}
}
核心逻辑就 10 行:data: 前缀截掉 → JSON.parse → 取 choices[0].delta。不加验证层,不出错就行。
2. streamWithTools:工具调用循环的核心
AI 完成"创建一个 React 组件"需要多步:先 read_file 看结构,再 write_file 建文件,最后 run_terminal 跑测试。一轮做不到,所以引擎核心是一个循环:
export async function streamWithTools(
provider: ModelProvider,
messages: ApiMessage[],
model: string,
onChunk: (chunk: string) => void,
onDiffStream?: (partial: string) => void,
signal?: AbortSignal,
threadId?: string,
): Promise {
const MAX_ITERATIONS = 20;
const tools = toOpenAiToolsForMode();
let currentMessages = [...messages];
for (let iter = 0; iter ();
for (const tc of delta.toolCalls) {
const idx = tc.index ?? 0;
const acc = toolAcc.get(idx) ?? {
index: idx,
id: "",
type: "function",
function: { name: "", arguments: "" }
};
if (tc.id) acc.id = tc.id;
if (tc.function?.name) acc.function.name += tc.function.name;
if (tc.function?.arguments) acc.function.arguments += tc.function.arguments;
toolAcc.set(idx, acc);
}
注意 name 和 arguments 是逐字符累加。SSE 流式传输里,一个工具调用名可能被拆成多个 chunk 送达,不能赋值只能追加。
XML 兜底:
const filter = new ToolCallFilter((xml) => {
const parsed = parseToolCallXml(xml);
if (parsed) {
xmlToolCalls.push({
index: 9000 + xmlToolCalls.length,
id: `xml_${Date.now()}_${xmlToolCalls.length}`,
type: "function",
function: { name: parsed.name, arguments: JSON.stringify(parsed.args) }
});
}
});
ToolCallFilter 是流式 XML 解析器,逐字符扫描输出流,发现 `` 标签就拦截并伪装成标准 tool_calls 格式。AI 最终输出的文本里不含这些标签。
最后合并两种来源:
const toolCalls = [...toolAcc.values(), ...xmlToolCalls].filter((t) => t.function.name);
4. 请求体压缩:朴素策略 + DeepSeek 413 踩坑
多轮工具调用后请求体会膨胀,某些网关(DeepSeek)对 body 大小严格限制,超了直接返回 413。这是实际踩过的坑。
export const BODY_LIMIT = 5_500_000; // 约 5.5MB,留余量
export function shrinkMessagesForBody(messages: ApiMessage[]): ApiMessage[] {
const total = messages.reduce((n, m) => n + contentLen(m.content), 0);
if (total m.role === "tool").length)
));
return messages.map((m) => {
if (m.role !== "tool" && m.role !== "assistant") return m;
if (typeof m.content !== "string") return m;
return { ...m, content: shrinkText(m.content, m.role === "tool" ? perTool : 20_000) };
});
}
策略很朴素:
- 总量 < 70% 上限 → 不压缩,避免无谓开销
- 需要压缩 → 每个工具消息压缩到 8KB 以上(按剩余空间取均值),assistant 消息压到 20KB
- 发送前再加一道
assertBodyUnderLimit,超了直接抛错,提示用户新开对话
DeepSeek 网关 413 是第一个版本撞上的。不加这层,流式请求发出去直接被网关拒掉,用户端看不到任何报错。
5. 三层中止信号检查
用户中途取消生成,三个位置都要检查 signal:
// 1. 每轮循环开始前
if (isAborted(signal)) throw new Error("已停止生成");
// 2. 每次流式读取时
if (isAborted(signal)) {
await reader.cancel();
throw new Error("已停止生成");
}
// 3. 工具执行前
if (isAborted(signal)) throw new Error("已停止生成");
isAborted 基于 AbortController 包装,就一行:
export function isAborted(signal?: AbortSignal): boolean {
return signal?.aborted ?? false;
}
三层检查缺一不可:循环入口查一次挡住下一轮;读取中取消必须 reader.cancel() 释放连接;工具执行前查是防止副作用继续跑。
6. Anthropic 例外:不走工具循环
Anthropic 的工具调用协议和 OpenAI 不兼容,实测直接走纯流式,不收集工具调用,不循环:
if (provider !== "anthropic") {
content = await streamWithTools(provider, fullMessages, model, ...);
} else {
content = await streamOpenAICompatible(provider, fullMessages, model, ...);
}
streamOpenAICompatible 是 streamWithTools 的简化版,没有工具收集,没有循环,纯粹读 SSE 流:
export async function streamOpenAICompatible(...) {
const res = await fetch(url, { method: "POST", headers, body, signal });
const reader = res.body.getReader();
while (true) {
const { value, done } = await reader.read();
if (done) break;
// 解析 line,onChunk 回调
}
return full;
}
7. Bun 带来的整体收益
整个 Agent 引擎 300 行代码,没有引入任何 AI SDK(没有 openai、@anthropic-ai/sdk),纯 TypeScript + Bun 内置 API。对比 Node 实现:
| 能力 | Node.js 实现 | Bun 实现 |
|---|---|---|
| SSE 流式读取 | node-fetch + eventsource 或手动解析 | fetch 原生 + getReader() |
| 子进程管理 | child_process.spawn 冗长 API | Bun.spawn 一行搞定 |
| 类型检查 | 需要 tsc 编译步骤 | bun run 直接执行 .ts |
| 包管理 | npm install + node_modules | bun install 快约 10 倍 |
不需要厚框架,一个函数就能撑起一个完整的 Agent 循环。