从零实现一个 ReAct Agent Loop - 可中断、可流式、多模型支持

从零实现一个 ReAct Agent Loop------可中断、可流式、多模型支持

这是 code-artisan 拆解系列第二篇。

上一篇:我用 1 个月写了一个 Web AI Coding Agent,今天开源------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)的循环很短,一张图能讲清楚:

flowchart LR Start([用户消息]) --> A[调用 LLM] A --> B{有 tool_calls?} B -- 否 --> End([返回最终回答]) B -- 是 --> C[执行所有工具] C --> D[追加结果到消息历史] D --> A

为了让代码能直接跑,我们用 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。我们要做的事:

  1. 主循环每一步开始时检查 signal.aborted
  2. LLM 请求带上 signal
  3. 工具调用也要能感知到 signal(比如里面跑 bash 的话,也需要kill掉)
  4. 不要直接 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------白白浪费了流式能力。

但要真把流式接出去给消费方,有几个不舒服的地方要先想清楚:

  1. 流式事件天然碎散 :OpenAI 的 delta 是 { content: "北" }{ tool_calls: [{ index: 0, function: { arguments: "{\"ci" } }] } 这种碎片
  2. tool_use 的 input 在流式途中是半成品 JSON ------{"city": "上{"city": "上海{"city": "上海"}。消费方拿到半成品 JSON.parse 会炸
  3. 消费方场景不一样:前端要每个 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_callsrole: "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_startcontent_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 传不同的 baseURLmodel

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_filewrite_filebashgrepglob......

下一篇会带大家实现一下 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 ⭐。

相关推荐
冴羽yayujs1 小时前
GitHub 前端热榜项目 - 日榜(2026-05-10)
前端·github
CAE虚拟与现实1 小时前
前后端调试常用工具大全
前端·后端·vue·react·angular
iuu_star1 小时前
跑通最简单的Vue3+Python前后端分离项目
前端·vue.js·python
大龄码农有梦想1 小时前
AI 智能体核心组件:Tool、MCP 与 Skills 的区别、标准与协同架构
人工智能·agent·智能体·ai工具·tool·mcp·skills
AZaLEan__1 小时前
CSS3:从 2D 变换到 3D 翻转
前端·3d·css3
剑神一笑1 小时前
Linux du 命令深度解析:从磁盘占用统计到目录空间分析
linux·运维·前端
johnny2331 小时前
AI Coding Agent:OMO、Goose、Ralph Wiggum、x-cmd、HolyClaude、Halo
agent
weixin_446260851 小时前
AI驱动的前沿前端技术栈深度解析:从模型能力到UI封装的完整生命周期
前端·人工智能·ui
程序猿编码1 小时前
Linux 高负载场景下 Web 服务访问日志极速定位工具实现解析(C/C++代码实现)
linux·服务器·c语言·前端·c++