从零实现一个 ReAct Agent Loop------可中断、可流式、多模型支持
这是 code-artisan 拆解系列第二篇。
前言
现在 ReAct loop 的核心实现(模型思考 → 调工具 → 拿结果 → 继续思考,直到模型不再发 tool_use)已经不再是秘密,网上可以看到各种模板。
但要把它变成能反复使用的 SDK 以及可在生产环境中跑,光这个循环远远不够。还需要考虑很多问题,比如:
-
用户中途点取消,怎么干净的停下来 - 不让 tool_use 变成没有 tool_result 的孤儿,有一些LLM厂商在这种情况下直接报错
-
如何定义流式结构,让前端更为方便的接入渲染逻辑,更快的呈现在用户面前。
-
在流式基础上,如何优雅的处理模型同时调了多个工具执行,增加客户端体验
-
想同时支持 Anthropic 和 OpenAI 兼容协议(DeepSeek、Kimi 等),需要屏蔽两边在 tool_use、流式事件、消息角色上的差异
当然除了上述这些还有很多需要做的,我会在接下来的系列文章中再带大家一起优化。这篇我们先打好地基,从零实现一个可中断、可流式、多模型可插拔的 ReAct Agent Loop。
文章每一 Feature 都有一个独立的小例子可以直接在线跑,链接放在每节末尾------一边读一边动手,比闷头读代码顺得多。
Part 1:核心概念 + 最简实现
ReAct(Reasoning + Acting)的循环很短,一张图能讲清楚:
为了让代码能直接跑,我们用 DeepSeek 作为示例(它走 OpenAI 协议,所以用官方的 openai 这个包就够了)。先准备一个简单工具:根据城市名返回假的天气。
ts
// OpenAI 接受的工具定义结构
const tools = [
{
type: "function" as const,
function: {
name: "get_weather",
description: "查询某个城市的天气",
parameters: {
type: "object",
properties: { city: { type: "string" } },
required: ["city"],
},
},
},
];
// 工具的具体实现
const toolImpls: Record<string, (input: any) => Promise<string>> = {
get_weather: async ({ city }) => {
await new Promise((r) => setTimeout(r, 300));
return `${city} 今天 25°C 晴`;
},
};
主循环:由于我们是以SDK的角度去实现,并且后续代码会越来越多,所以我们采用 Class 来实现,方便维护。
ts
export class Agent {
// LLM 实例
private client: OpenAI;
// 内部维护消息列表,这一步为了让LLM有记忆,感知到上下文。
private messages: any[] = [];
constructor(apiKey: string) {
// 创建实例
this.client = new OpenAI({
apiKey,
baseURL: "https://api.deepseek.com/v1",
});
}
async run(input: string) {
console.log("[user]", input);
this.messages.push({ role: "user", content: input });
// 创建 while agent loop,内部判断无工具调用时再退出
while (true) {
console.log("[step] 调用 deepseek-chat ...");
const resp = await this.client.chat.completions.create({
model: "deepseek-chat",
messages: this.messages,
tools,
});
const assistant = resp.choices[0].message;
this.messages.push(assistant);
// 调出循环的唯一条件,没有工具调用,也就是LLM用纯文本回复
if (!assistant.tool_calls?.length) {
console.log("[assistant]", assistant.content ?? "");
return;
}
// 打印工具调用的日志
const summary = assistant.tool_calls
.map((c: any) => `${c.function.name}(${c.function.arguments})`)
.join(", ");
console.log("[tool_calls]", summary);
// 批量调用工具
const toolMsgs = await Promise.all(
assistant.tool_calls.map(async (call: any) => {
const args = JSON.parse(call.function.arguments);
const result = await toolImpls[call.function.name](args);
console.log("[tool_result]", `${call.function.name}: ${result}`);
return { role: "tool" as const, tool_call_id: call.id, content: result };
})
);
this.messages.push(...toolMsgs);
}
}
}
上面就是最简化的ReAct Agent Loop版本。我们可以直接使用该类运行一下
🟢 在线试一下 → Part 1 Playground
js
const agent = new Agent(process.env.DEEPSEEK_API_KEY ?? "");
await agent.run("北京和上海今天天气怎么样?");
Part 2:支持用户中途取消
最常见的场景:模型已经生成到一半,用户嫌它跑偏,点了停止按钮。
最自然的方式是 Web 标准的 AbortController ------你拿到一个 signal,把它传给所有需要支持中止的异步操作(fetch、setTimeout、子进程......),调一次 controller.abort(),所有挂在这个 signal 上的操作都会收到通知。
OpenAI / Anthropic 的官方 SDK 都支持给请求传 signal。我们要做的事:
- 主循环每一步开始时检查
signal.aborted - LLM 请求带上 signal
- 工具调用也要能感知到 signal(比如里面跑 bash 的话,也需要kill掉)
- 不要直接 throw
AbortError------最后一条 tool_call 还没执行完就抛,下次 resume 模型看到没有 tool_result 配对的孤儿 tool_call,会直接报错。所以我们要做就是"干净退出"。
首先工具层面要支持可取消,修改 Part 1 的工具,当我们工程足够复杂时,我们工具避免不了需要感知上下文,所以这里不单纯传入signal,而是定义第二参数为ctx,作为后续扩展使用
ts
const toolImpls: Record<string, (input: any, ctx: { signal?: AbortSignal }) => Promise<string>> = {
get_weather: async ({ city }, { signal }) => {
await new Promise((r) => setTimeout(r, 300));
// 判断已取消直接reject
if (signal?.aborted) throw signal.reason;
return `${city} 今天 25°C 晴`;
},
};
然后我们在 Agent 类中增加 abort 方法,用于外部控制取消
ts
export class Agent {
// 增加 _abortController 变量
private _abortController: AbortController | null = null;
// 增加abort方法,接受取消原因
abort(reason?: unknown) {
this._abortController?.abort(reason);
}
async run(input: string) {
// 每次 agent loop 之前,生成abort signal,然后传递下去
this._abortController = new AbortController();
const signal = this._abortController.signal;
// 增加 try catch,捕获abort错误
try {
while (true) {
// 判断已终止就退出循环
if (signal.aborted) {
console.log("[aborted]", String(signal.reason));
return;
}
console.log("[step] 调用 deepseek-chat ...");
const resp = await this.client.chat.completions.create(
{ model: "deepseek-chat", messages: this.messages, tools },
{ signal }
);
const assistant = resp.choices[0].message;
this.messages.push(assistant);
if (!assistant.tool_calls?.length) {
console.log("[assistant]", assistant.content ?? "");
return;
}
console.log("[tool_calls]", `${assistant.tool_calls.length} 个并发调用`);
// 调用工具前也需要判断是否终止
if (signal.aborted) {
console.log("[aborted] before tool execution");
return;
}
const toolMsgs = await Promise.all(
assistant.tool_calls.map(async (call: any) => {
const args = JSON.parse(call.function.arguments);
// 传递给工具,工具内部自行处理
const result = await toolImpls[call.function.name](args, { signal });
console.log("[tool_result]", `${call.function.name}(${args.city}): ${result}`);
return { role: "tool" as const, tool_call_id: call.id, content: result };
})
);
this.messages.push(...toolMsgs);
}
} catch (err: any) {
// 捕获错误,如果是取消导致的异常,不用处理
if (signal.aborted) {
console.log("[aborted] LLM request aborted");
// 补齐tool_result,防止llm sdk报错,干净退出
this._finalizeOnAbort(String(signal.reason));
return;
}
throw err;
} finally {
this._abortController = null;
}
}
// 补齐tool_result,防止llm sdk报错,干净退出
private _finalizeOnAbort(reason: string) {
const last = this.messages.findLast((m: any) => m.role === "assistant");
if (!last?.tool_calls?.length) return;
const done = new Set(
this.messages.filter((m: any) => m.role === "tool").map((m: any) => m.tool_call_id)
);
for (const call of last.tool_calls) {
if (done.has(call.id)) continue;
this.messages.push({
role: "tool",
tool_call_id: call.id,
content: `[interrupted] ${reason}`,
});
console.log("[tool_result]", `${call.function.name}: [interrupted] ${reason}`);
}
}
}
调用方现在可以这样用:
🟢 在线试一下 → Part 2 Playground
ts
const agent = new Agent(process.env.DEEPSEEK_API_KEY ?? "");
console.log("[dim] 5 秒后会自动 abort,模拟用户点了 Stop 按钮");
signal?.addEventListener("abort", () => agent.abort(signal.reason));
const timer = setTimeout(() => agent.abort("user clicked stop (5s timeout)"), 5000);
try {
await agent.run("帮我查 5 个城市的天气:北京、上海、深圳、杭州、广州");
} finally {
clearTimeout(timer);
}
注意几个细节:
-
abort 检查点放在两个地方:每次新 step 开头、tool 调用前。模型已经返回了 assistant 消息但 tool 还没跑,这时候用户 abort,要保证 assistant 消息不被丢失(已经 push 进 messages 了),同时跳过 tool 执行
-
aborted 之后 messages 状态要保持自洽 :如果 push 了 assistant 消息(里面有 tool_calls)但还没 push tool_result,下次 resume 这条对话之前要补上占位 tool_result。模型协议层面不允许 tool_call 没有对应 tool_result 。具体实现代码看
_finalizeOnAbort
Part 3:流式事件结构------前端要的是渐进式可见
到这里循环已经能跑、能停了。但要拿去给前端集成,我们还要支持流式输出,没有流式输出用户会等待很长时间才可以看到结果,用户都已经习惯「看着字一个一个蹦出来」的预期。
LLM API 一直支持流式:模型从生成第一个 token 起,每个 chunk 都会推过来。我们之前的写法(await client.chat.completions.create(...) 不带 stream: true)等于把整条流消费完才返回最终 message------白白浪费了流式能力。
但要真把流式接出去给消费方,有几个不舒服的地方要先想清楚:
- 流式事件天然碎散 :OpenAI 的 delta 是
{ content: "北" }、{ tool_calls: [{ index: 0, function: { arguments: "{\"ci" } }] }这种碎片 - tool_use 的 input 在流式途中是半成品 JSON ------
{"city": "上、{"city": "上海、{"city": "上海"}。消费方拿到半成品JSON.parse会炸 - 消费方场景不一样:前端要每个 token 都更新、CLI 只关心最终消息、持久化只想要定稿后的消息
我们的设计目标:让消费方拿到的事件流里不出现"碎片"概念,只暴露两类信号:
partial事件 :模型说话过程中的当前完整快照------文本累积到了哪、工具调用拼到哪一步。每次 yield 都是替换式的完整状态,消费方setMessage(partial)就能渲染message事件:定稿 - 这条 assistant 已经讲完了 / 这条 tool_result 已经收齐了。消费方拿到就可以持久化、关闭流式响应。
我们把累积逻辑藏在 Agent 内部,消费方只面对两类干净的事件:
ts
type AgentEvent =
| { type: "partial"; message: any }
| { type: "message"; message: any };
class Agent {
// ... 同 Part 2 的字段 + abort + _finalizeOnAbort
async *stream(input: string): AsyncGenerator<AgentEvent> {
// ...
}
// 复用stream方法
async invoke(input: string): Promise<any[]> {
const collected: any[] = [];
for await (const ev of this.stream(input)) {
if (ev.type === "message") collected.push(ev.message);
}
return collected;
}
}
stream() 是流式入口,invoke() 是 CLI / 持久化场景的便捷方法------内部消费 stream,只收 message,所以不用维护两套实现。这一步就把"前端要打字效果、CLI 只要最终消息"两类消费方对齐到一份代码上了。
下面是 stream() 的核心逻辑结构跟 Part 2 完全一样,只是把"await client.chat.completions.create(...) 拿完整 message"换成"消费一段流式 chunk、累积成快照、每次更新 yield 一个 partial":
ts
async *stream(input: string): AsyncGenerator<AgentEvent> {
this._abortController = new AbortController();
const signal = this._abortController.signal;
this.messages.push({ role: "user", content: input });
try {
while (true) {
if (signal.aborted) {
this._finalizeOnAbort(String(signal.reason));
return;
}
// 关键变化:消费 stream,逐 token yield partial
const assistant = yield* this._streamAssistant(signal);
if (!assistant) return;
this.messages.push(assistant);
// 这里是完整的一条消息
yield { type: "message", message: assistant };
if (!assistant.tool_calls?.length) return;
if (signal.aborted) {
this._finalizeOnAbort(String(signal.reason));
return;
}
// 工具仍然使用 Promise.all,但这里存在问题,下一节会改造这里
const toolMsgs = await Promise.all(
assistant.tool_calls.map(async (call: any) => {
const args = JSON.parse(call.function.arguments);
const result = await toolImpls[call.function.name](args, { signal });
return { role: "tool" as const, tool_call_id: call.id, content: result };
})
);
for (const m of toolMsgs) {
this.messages.push(m);
yield { type: "message", message: m };
}
}
} catch (err: any) {
if (signal.aborted) {
this._finalizeOnAbort(String(signal.reason));
return;
}
throw err;
} finally {
this._abortController = null;
}
}
_streamAssistant 把 OpenAI 的流式 chunk 累积成一个完整的 assistant message,每次有更新就 yield 一个 partial:
这里希望读者能够明白这么做的意义,openAI提供的delta理论上可以直接使用,但我们这一层做一下累加在yield,主要方便消费该SDK的地方更方便,尤其是前端渲染,不需要在自己累计,而是直接做替换覆盖即可。
ts
private async *_streamAssistant(signal: AbortSignal): AsyncGenerator<AgentEvent, any> {
const stream = await this.client.chat.completions.create(
// 我们传入stream: true,让LLM SDK按流式输出
{ model: "deepseek-chat", messages: this.messages, tools, stream: true },
{ signal }
);
// 累加的文本
let text = "";
// 累加的工具调用
const toolCalls: { id: string; name: string; argsJson: string }[] = [];
// 快照
let snapshot: any = null;
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta;
if (!delta) continue;
// 累加文本
if (delta.content) text += delta.content;
for (const tc of delta.tool_calls ?? []) {
const idx = tc.index;
toolCalls[idx] ??= { id: "", name: "", argsJson: "" };
if (tc.id) toolCalls[idx].id = tc.id;
if (tc.function?.name) toolCalls[idx].name = tc.function.name;
// 累加工具调用参数
if (tc.function?.arguments) toolCalls[idx].argsJson += tc.function.arguments;
}
// 组装当前快照,
snapshot = {
role: "assistant",
content: text || null,
tool_calls: toolCalls.length
? toolCalls.map((tc) => ({
id: tc.id,
type: "function",
function: { name: tc.name, arguments: tc.argsJson },
}))
: undefined,
};
yield { type: "partial", message: snapshot };
}
return snapshot;
}
注意三个细节:
-
snapshot 永远是当前的完整状态:text 累积到哪、tool_calls 拼到哪一步,消费方拿到立刻能用,不用自己拼
-
tool_calls.arguments不在这里JSON.parse:流式途中可能是{"city": "上这种半成品;消费方碰不到这个原始字符串,只看到我们 yield 的 partial message -
yield*把内层 generator 的 yield 透传到外层,最后通过return snapshot把累积好的最终消息交给主循环------主循环就当它是一个普通的await provider.invoke()调用,差别只是中间多出来一串 partial
消费方两种姿势:
ts
// 前端打字效果
for await (const ev of agent.stream("...")) {
if (ev.type === "partial") setMessage(ev.message);
if (ev.type === "message") commitMessage(ev.message);
}
// CLI / 持久化
const messages = await agent.invoke("...");
🟢 在线试一下(看 [step] / [tool_result] 之间穿插的字符级流式输出)→ Part 3 Playground
到这里,前端已经能做打字机效果。但还有一个"看着卡"的场景没解决:工具并行调用还是 Promise.all 整批收齐。一个慢工具会拖慢一整批。
Part 4:Promise.race - 多工具并行支持流式,增加客户端体验
Part 3 之后,模型生成的 assistant 文本能逐 token 流到前端。但工具执行那段还是这个写法:
ts
const toolMsgs = await Promise.all(
assistant.tool_calls.map(async (call) => { ... })
);
for (const m of toolMsgs) {
yield { type: "message", message: m };
}
Promise.all 必须等所有工具都 settle 才往下走。假设模型一次发了 3 个 tool_call:两个 read_file 200ms 完成,一个 web_search 3 秒。消费方要等满 3 秒才能看到任何 tool_result,前面 2.8 秒都是干等 - 前端那边表现就是「又卡住了」。
理想体验:先完成的先 yield 给消费方,让前端实时刷新工具结果。Promise.race 是天然的工具------它在多个 promise 中选最先 settle 的那个返回。但 race 一次只 settle 一个,剩下的怎么办?
我们维护一个 pending 集合,每 race 完一个就把它从集合里移除,循环到集合为空:
ts
private async *_act(toolCalls: any[], signal: AbortSignal): AsyncGenerator<any> {
const pending = toolCalls.map((call, idx) =>
(async () => {
const args = JSON.parse(call.function.arguments);
const result = await toolImpls[call.function.name](args, { signal });
return { idx, tool_call_id: call.id, content: result };
})()
);
const remaining = new Set(pending.map((_, i) => i));
while (remaining.size > 0) {
const candidates = [...remaining].map((i) => pending[i]);
const winner = await Promise.race(candidates);
// 移除掉已经resolve的
remaining.delete(winner.idx);
// 发送流式事件,把已完成发送过去
yield { role: "tool" as const, tool_call_id: winner.tool_call_id, content: winner.content };
}
}
这是个 AsyncGenerator------每次 yield 一个已完成的 tool 结果。主循环里那段 Promise.all 替换成消费它:
ts
// stream() 里 Promise.all 那段
for await (const m of this._act(assistant.tool_calls, signal)) {
this.messages.push(m);
yield { type: "message", message: m };
}
主循环结构一行没变------for await 不关心是 Promise.all 包出来的数组、还是 race 出来的 generator,只关心「拿到 tool_result 就 push + yield」。
🟢 在线试一下(一个 300ms 工具 + 一个 3s 工具,对比
[+325ms]和[+3010ms]时间戳)→ Part 4 Playground
值得说的几点:
-
race 不是无脑选 。如果消费方根本不在乎"边出边消费"(批处理、CLI 直接
console.log收尾),上一节的Promise.all简洁太多。流式才是 race 的正当理由 -
顺序不保证 ------race 的产物按完成时间排序,不按 tool_call 在原数组里的位置。LLM 协议本来就靠
tool_call_id配对,不依赖顺序 -
单个工具抛错应该被 catch 住、转成
Error: xxx字符串塞进 tool_result - 不能让一个工具炸掉整个 run。所以主循环不会关心工具执行失败,下一篇讲工具设计的时候我们再展开细说。
Part 5:Provider 抽象------屏蔽 OpenAI / Anthropic 协议差异
到这里,循环已经能跑、能停、能流式、能边出边消费------但它和 openai 这个 SDK 完全粘死。
换 Claude?两边的协议差距比想象中大:
| 维度 | OpenAI 协议(DeepSeek / Kimi / 通义都兼容) | Anthropic 协议 |
|---|---|---|
| 工具调用在哪 | assistant message 顶层 tool_calls 字段 |
content block 里 { type: "tool_use" } |
| 工具结果在哪 | role: "tool" 单独消息 |
role: "user" 消息里的 { type: "tool_result" } block |
| 文本怎么放 | message.content 是 string |
content 是 block 数组 [{ type: "text", text }] |
| 流式事件 | chunk.choices[0].delta 这种 delta 模型 |
content_block_start / _delta / _stop 嵌套事件 |
如果主循环里到处写 assistant.tool_calls、role: "tool"、chunk.choices[0].delta,换协议就是重写。所以要抽一层。
内部消息格式选哪边?
两条路:
- 抄一边的协议(比如直接用 OpenAI 那套)------ 省事,但绑死厂商,遇到 Anthropic 的 thinking signature、Vercel AI SDK 的 file part 这些扩展能力会很别扭
- 自己定义 - 多一点工作量,但每个 provider 各自做转换,主循环只认自己的格式
这里我们不闭门造车。参考 Anthropic 的 block-based 设计(更通用,文本/图片/工具调用/思考都能放一起),简化一些字段,得到自己的内部格式:
ts
type ContentBlock =
| { type: "text"; text: string }
| { type: "tool_use"; id: string; name: string; input: any }
| { type: "tool_result"; tool_use_id: string; content: string };
interface Message {
role: "system" | "user" | "assistant" | "tool";
content: ContentBlock[];
}
interface Tool {
name: string;
description: string;
parameters: any;
}
LLMProvider 接口
只定义一个 stream------invoke 的语义可以靠"消费 stream 到最后一个 yield"完全复用 stream 的实现,没必要让 provider 维护两套:
ts
interface LLMProvider {
stream(params: {
messages: Message[];
tools: Tool[];
signal?: AbortSignal;
}): AsyncGenerator<Message>;
}
stream() 每次 yield 都是完整的 message 快照(替换式,不是增量)和 Part 3 我们对消费方的承诺一脉相承,只不过换成了 block-based 格式。
主循环只认 Provider
Agent 的构造函数从「接收 apiKey 自己 new OpenAI」变成「接收一个 Provider」。主循环里所有 this.client.chat.completions.create 全部换成 this.provider.stream(...):
ts
class Agent {
constructor(private provider: LLMProvider, private label: string) {}
async *stream(input: string): AsyncGenerator<AgentEvent> {
// ... 同 Part 4 的骨架
for await (const snap of this.provider.stream({ messages: this.messages, tools, signal })) {
assistant = snap;
yield { type: "partial", message: snap };
}
// ... 后面 _act + _finalizeOnAbort 都一样,只是消息和 tool_use 用 block 形式
}
}
主循环的代码量没增加多少,但它现在不知道、也不在乎底下走的是 OpenAI 还是 Anthropic。
OpenAIProvider
把 OpenAI 的流式 delta 累积成 block-based snapshot:
ts
class OpenAIProvider implements LLMProvider {
constructor(private client: OpenAI, private model: string) {}
async *stream({ messages, tools, signal }): AsyncGenerator<Message> {
const stream = await this.client.chat.completions.create({
model: this.model,
messages: this.toOpenAIMessages(messages),
tools: tools.map((t) => ({
type: "function" as const,
function: { name: t.name, description: t.description, parameters: t.parameters },
})),
stream: true,
}, { signal });
let text = "";
const toolCalls: { id: string; name: string; argsJson: string }[] = [];
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta;
if (!delta) continue;
if (delta.content) text += delta.content;
for (const tc of delta.tool_calls ?? []) {
const idx = tc.index;
toolCalls[idx] ??= { id: "", name: "", argsJson: "" };
if (tc.id) toolCalls[idx].id = tc.id;
if (tc.function?.name) toolCalls[idx].name = tc.function.name;
if (tc.function?.arguments) toolCalls[idx].argsJson += tc.function.arguments;
}
yield this.snapshot(text, toolCalls);
}
}
private snapshot(text: string, toolCalls: any[]): Message {
const blocks: ContentBlock[] = [];
if (text) blocks.push({ type: "text", text });
for (const tc of toolCalls) {
let input: any = {};
try { input = JSON.parse(tc.argsJson); } catch {}
blocks.push({ type: "tool_use", id: tc.id, name: tc.name, input });
}
return { role: "assistant", content: blocks };
}
// toOpenAIMessages: 把内部 block-based 格式转回 OpenAI 协议
// ------ assistant.tool_use → assistant.tool_calls,role:"tool" → role:"tool" + tool_call_id
// 篇幅原因省略,可以参考仓库里的实现
}
AnthropicProvider
Anthropic 的流式事件是 content_block_start → content_block_delta(多次)→ content_block_stop 嵌套结构,每个 content block 一组------形态完全不同,但产物要一样是 block-based message:
ts
class AnthropicProvider implements LLMProvider {
constructor(apiKey: string, private model: string) {
this.client = new Anthropic({ apiKey });
}
async *stream({ messages, tools, signal }): AsyncGenerator<Message> {
const stream = this.client.messages.stream({
model: this.model,
max_tokens: 1024,
messages: messages.map((m) => this.toAnthropicMessage(m)),
tools: tools.map((t) => ({
name: t.name,
description: t.description,
input_schema: t.parameters,
})),
}, { signal });
let text = "";
const toolUses: { id: string; name: string; argsJson: string }[] = [];
for await (const ev of stream) {
if (ev.type === "content_block_start" && ev.content_block.type === "tool_use") {
toolUses[ev.index] = {
id: ev.content_block.id,
name: ev.content_block.name,
argsJson: "",
};
} else if (ev.type === "content_block_delta") {
if (ev.delta.type === "text_delta") text += ev.delta.text;
else if (ev.delta.type === "input_json_delta") toolUses[ev.index].argsJson += ev.delta.partial_json;
}
yield this.snapshot(text, toolUses);
}
}
// toAnthropicMessage: role:"tool" → 一条 user 消息里的 tool_result block
// 同样篇幅原因省略
}
两个 provider 的产物完全一样------一串 Message 快照,每个都是 block 数组。主循环只认这个形态。
真的换一下
DeepSeek、Kimi、通义这些走的都是 OpenAI 兼容协议------只要给 OpenAIProvider 传不同的 baseURL 和 model:
ts
const deepseek = new OpenAIProvider(
new OpenAI({ apiKey: process.env.DEEPSEEK_API_KEY!, baseURL: "https://api.deepseek.com/v1" }),
"deepseek-chat"
);
const kimi = new OpenAIProvider(
new OpenAI({ apiKey: process.env.KIMI_API_KEY!, baseURL: "https://api.moonshot.cn/v1" }),
"kimi-k2-turbo-preview"
);
const claude = new AnthropicProvider(process.env.ANTHROPIC_API_KEY!, "claude-sonnet-4-6");
await new Agent(deepseek, "deepseek").invoke("北京今天天气怎么样?");
await new Agent(claude, "claude").invoke("北京今天天气怎么样?");
主循环一行没改。
🟢 在线试一下(同一句问题,DeepSeek 跑完接着 Claude 跑,主循环代码不变)→ Part 5 Playground
写在最后
到这里,这个 Agent Loop 已经具备了:
- Part 1:ReAct 主循环
- Part 2:可中断 + 干净退出(AbortController + tool_use 不留孤儿)
- Part 3:流式事件结构(partial / message 双类信号;invoke 复用 stream)
- Part 4:多工具并发 + 边完成边推送(Promise.race,前端早消费)
- Part 5:多模型可插拔(block-based 内部格式 + LLMProvider 抽象)
但它本质上还只是个"会用工具的聊天机器人"------能查个假的天气、调个假的 API。要让它真的变成 Coding Agent ,需要一套完整的工具:read_file、write_file、bash、grep、glob......
下一篇会带大家实现一下 code-artisan 的工具系统:
defineTool怎么设计、Zod schema 和 LLM 的 JSON Schema 怎么互转bash工具的run_in_background模式怎么把"启动 dev server 永远卡死"这个坑解掉- 工具的错误怎么隔离,1个 tool 挂了不该让整个 run 炸
- 怎么在运行过程中"主动提醒"模型该调哪个工具
完整代码在 GitHub:github.com/lhz960904/c...(这篇文章拆的核心文件是 packages/agent/core/agent.ts)。如果觉得这个项目对你有帮助,欢迎点个 star ⭐。