七天从零实现 GeekAgent

从零实现 GeekAgent —— Day2 工具调用循环

约 8 分钟github/7days-geekagent#Agent#Harness

本文要点

7天从零实现Agent教程,用 TypeScript 动手写一个最简单的 Agent/Harness。Day 2 搭建工具调用循环:模型可以请求 get_current_time,程序执行后把真实时间交回模型,为后续命令与文件工具打下基础。

Day 2:让模型第一次「动手」——工具调用循环

Day 1 跑通了多轮对话,今天再接上工具调用:模型请求 get_current_time,程序执行并回传真实时间,模型再继续回答。

0. 标题从哪来:工具调用循环是什么

标题里有两个词要先讲清,否则后面没法展开。

工具调用(tool calling / function calling):模型本来只能生成文本。OpenAI 兼容接口允许我们在请求里附上一份「工具清单」:每个工具写清名字、用途和参数格式。遇到查实时时间这类问题时,模型可以不直接回答,而是输出约定好的 JSON,指明「调用 get_current_time,参数是 {}」。这就叫工具调用。

循环:模型下一次就完事了吗?不是。它下了单,我们得真的去执行、把结果拿回来再喂给它,它才据此组织最终回答。所以完整链路是「请求 → 模型调用工具 → 我们执行 → 结果回传 → 模型继续」,一轮不够就再来一轮,直到它给出不调工具的纯文本回答。把这条链路写成一个 for 循环,就是 Day 2 的全部工作。

一句话:工具调用循环 = 模型提出调用 → 程序执行 → 结果回传 → 模型继续。后面接入 Shell、文件读写、搜索和网页读取,复用的都是这条循环。

1. 为什么让模型「动手」

Day 1 的模型只会聊天。问它「现在几点」,它只能凭训练数据瞎猜一个——因为它根本没有「看时钟」的能力。一个只会聊天的模型,再能说也不是 Agent,只是个 chatbot。

Agent 和 chatbot 的一个重要区别,是能否调用外部能力获取新信息或执行动作。查时间、跑命令、读文件、调 API,都需要程序真正完成。Day 2 先用最简单的时间工具,把「模型提出调用 → 程序执行 → 结果回传」跑通。

2. 目标:把工具调用循环打通

Day 2 只做一件事:模型返回 tool_call → 我们执行 → 结果回传 → 模型继续,直到它给出最终回答。验收标准是两条:

  1. 询问“现在几点”时,模型返回 get_current_timetool_call,程序执行工具,并用同一个 tool_call_id 把真实时间放回 history;
  2. 模型读到 tool 消息后继续请求,最终用工具结果回答;如果再次请求工具,循环会继续,直到返回普通文本。

当天代码行数:5 个源文件共 268 行,新增约 56 行 tools.ts,改造约 30 行 chat.ts

3. 设计:先约定工具、消息和结果

3.1 三个约定

  1. 工具 = 一个对象。名字 + 描述 + 参数 JSON Schema + 一个 run 函数。模型只会看到清单里声明的工具,run 才是我们这边的真实世界。
  2. history 依然是那个数组。工具调用只是一种新的 message 角色:assistant 发起的 tool_calls,以及回传的 tool 消息。
  3. 结果只有字符串。工具 run 的结果一律转成字符串回传给模型。真实世界(时间、命令输出、文件内容)最后都折叠成一行文本,模型层面不需要任何结构化协议。

3.2 为什么不由程序匹配「问题 → 工具」

我们可能会想:模型为啥会主动调工具?是不是我们代码里写了「问题含『几点』→ 调 get_current_time」的关键词匹配?

不是。恰恰相反,整个链路里没有一行「问题关键词 → 工具」的映射,这正是工具调用设计的精髓。把必要性讲透:

  1. 每轮请求,我们把每个工具的「说明书」通过 tools 参数交给模型(toOpenAITools())——就是一个名字 + 一句描述 + 参数 Schema,代码里没有任何分支逻辑。
  2. 模型生成时自己判断:能凭知识回答的(“1+1 等于几?”),照常输出文本;涉及它不知道的实时信息(“现在几点?”),它看到 get_current_time 的描述正好对得上需求,就决定不写答案、改为发起一次工具调用——这是模型在训练中学到的「我知道的不查、不知道的用工」。
  3. 调用不是自然语言,而是固定协议:模型输出 JSON(name + arguments),OpenAI 兼容接口把它放进响应的 tool_calls 字段(流式下是拆成碎片的 delta.tool_calls)。我们的循环只负责认领、执行、回传,没有理解任何语义。
  4. 所以 description 写得好不好、parameters Schema 定义得准不准,直接决定模型调用得对不对——这也是为什么 Tool 抽象里「描述」是必备字段。

代码侧唯一的对应分叉在 chat.ts:本轮流式结束后若没有 tool_calls,就走「最终回答」分支。模型随时可以不理工具,直接开口答。

如果硬要在我们代码里写关键词匹配,会有两个致命问题:一是覆盖不全(用户换个问法就匹配不到),二是模型明明能自己判断却被迫走我们的死规则。把「要不要调工具」交给模型,才是少写代码、多覆盖的正确做法。

应用代码匹配关键词模型 function calling
谁选择工具我们写 if/else模型根据工具描述选择
支持新说法要继续补规则由模型理解自然语言
参数生成应用代码自己解析模型按 JSON Schema 生成
程序职责理解问题并执行工具声明工具、执行、回传结果

这里的 function calling 只是一套调用协议:模型不会真的执行函数,它只返回工具名和参数。真正接触时间、文件或网络的仍然是我们的程序,这也是工具调用循环不可省掉的原因。

4. 实现:先效果,后实现

运行后终端里会看到下面的效果(颜色用 HTML 还原):

You › 现在几点
稍等,我查一下……
[调用工具 get_current_time → 2026/8/26 20:42:11]
现在是晚上 8 点 42 分。
You › 

4.1 tools.ts —— 工具的「最小公约数」

Day 2 新增 tools.ts,完整内容如下:

// day2/tools.ts
import type { ChatCompletionTool } from 'openai/resources/chat/completions';

/**
 * 工具的最小抽象:一个名字 + 描述 + 参数 JSON Schema + 一个 run 函数。
 * Day 2 只有时间工具一个成员;后续每天往 TOOLS 数组里加即可(Day 4 再收编成注册表)。
 */
export interface Tool {
    name: string;
    description: string;
    /** OpenAI function 的 parameters 层(如 { type: 'object', properties, additionalProperties }) */
    parameters: Record<string, unknown>;
    run(args: Record<string, unknown>): Promise<string> | string;
}

/** 全部已接入工具。模型只会拿到这份清单里声明的函数。 */
export const TOOLS: Tool[] = [
    {
        name: 'get_current_time',
        description: '获取当前本地时间(Asia/Shanghai)。',
        parameters: { type: 'object', properties: {}, additionalProperties: false },
        run: () => new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }),
    },
];

/** 把内部 Tool 转成 OpenAI Chat Completions 的 tools 参数格式。 */
export function toOpenAITools(): ChatCompletionTool[] {
    return TOOLS.map((tool) => ({
        type: 'function',
        function: {
            name: tool.name,
            description: tool.description,
            parameters: tool.parameters,
        },
    }));
}

/**
 * 按名字执行工具。参数是模型生成的 JSON 字符串。
 * 任何报错都作为「结果文本」返回给模型——让模型自己读错误(失败自检后续会实现,先在这里埋线)。
 */
export async function execTool(name: string, argsJson: string): Promise<string> {
    const tool = TOOLS.find((t) => t.name === name);
    if (!tool) return `未知工具:${name}`;

    let args: Record<string, unknown> = {};
    try {
        args = argsJson ? JSON.parse(argsJson) : {};
    } catch {
        return `参数不是合法 JSON:${argsJson}`;
    }

    try {
        return await tool.run(args);
    } catch (err) {
        return `工具执行失败:${(err as Error).message}`;
    }
}

新增工具时,只要往 TOOLS 数组注册一个对象;调用循环继续通过统一接口执行。两个配套函数负责格式转换和查找执行:

  • toOpenAITools():把内部 Tool 转成 OpenAI 的 tools 参数格式(一个 type: 'function' 的包装),交给模型的是「说明书」而非 run

4.2 chat.ts —— 工具调用循环

Day 1 的 streamReply 是「请求一次 → 流式输出 → 结束」。Day 2 把它包成一个循环,最多转 5 圈(MAX_TOOL_TURNS)。完整方法如下:

// day2/chat.ts
async *streamReply(userInput: string): AsyncGenerator<string> {
  this.history.push({ role: 'user', content: userInput });
  try {
    for (let turn = 0; turn < MAX_TOOL_TURNS; turn++) {
      const stream = await this.client.chat.completions.create({
        model: this.model,
        messages: this.history,
        tools: toOpenAITools(),
        stream: true,
      });

      // 流式边攒内容边聚拢工具调用(delta 按 index 碎片下发)
      let answer = '';
      const calls = new Map<number, { id: string; name: string; args: string }>();
      for await (const chunk of stream) {
        const delta = chunk.choices[0]?.delta;
        if (delta?.content) {
          answer += delta.content;
          yield delta.content;
        }
        for (const tc of delta?.tool_calls ?? []) {
          let call = calls.get(tc.index);
          if (!call) {
            call = { id: '', name: '', args: '' };
            calls.set(tc.index, call);
          }
          if (tc.id) call.id = tc.id;
          if (tc.function?.name) call.name += tc.function.name;
          if (tc.function?.arguments) call.args += tc.function.arguments;
        }
      }

      const toolCalls = [...calls.values()];
      if (toolCalls.length > 0 && toolCalls.every((c) => c.name)) {
        // 模型要动手:先把这条 assistant 消息(含 tool_calls)记入 history
        toolCalls.forEach((c, i) => {
          if (!c.id) c.id = `call_${i}`; // 个别模型不返回 id,补一个稳定值
        });
        this.history.push({
          role: 'assistant',
          content: answer || null,
          tool_calls: toolCalls.map((c) => ({
            id: c.id,
            type: 'function' as const,
            function: { name: c.name, arguments: c.args },
          })),
        });
        // 逐个执行并把结果以 role=tool 消息回传,然后进入下一轮
        for (const c of toolCalls) {
          const result = await execTool(c.name, c.args);
          yield `\n[调用工具 ${c.name} → ${result}]\n`;
          this.history.push({ role: 'tool', tool_call_id: c.id, content: result });
        }
        continue;
      }

      // 没有工具调用,就是最终回答
      this.history.push({ role: 'assistant', content: answer });
      return;
    }
    // 轮次用尽仍未停下:兜底收尾,保持 history 有来有回
    yield '\n[工具调用轮次过多,已停止]';
    this.history.push({ role: 'assistant', content: '[工具调用轮次过多,已停止]' });
  } catch (err) {
    // 出错就回滚刚入队的用户消息,保持 history 只有「有来有回」的对话。
    this.history.pop();
    throw err;
  }
}

整体骨架就一个 for 循环:每轮发请求(携带 history + tools)→ 流式收 → 有完整 tool_calls 就执行并 continue 进下一轮,没有就当作最终回答 return

两处需要留意的实现细节,代码里都覆盖到了:

细节一:流式下 tool_calls 是碎片(正常的 streaming 处理)。 同一个工具调用的 idfunction.namefunction.arguments 会拆在多个 chunk 里下发,靠 index 对号入座,name/arguments字符串累加拼出来的(见上面循环里聚拢 calls 的那一段)。

细节二:个别模型不返回 tool_call_id 抽历史时 tool 消息必须对上 assistant 的 tool_call_id,缺了请求会 400。所以推入 history 前补齐:if (!c.id) c.id = \call_${i}`;(见上面 toolCalls.forEach` 那一段)。

4.3 一次工具调用后,history 完整长什么样

问「现在几点」,整个循环跑完,history 尾部追加的是这么 4 条(谁写的清清楚楚):

// day2/chat.ts(history 片段示意)
// ① 我们输入:用户原话(Chat 收到输入时 push)
{ role: 'user', content: '现在几点?' },

// ② 模型返回:决定不直接作答,改为调用工具(注意 content 是 null)
{ role: 'assistant', content: null,
  tool_calls: [{ id: 'call_0', type: 'function',
                 function: { name: 'get_current_time', arguments: '{}' } }] },

// ③ 我们回传:工具的真实输出(execTool 的结果,非模型生成)
{ role: 'tool', tool_call_id: 'call_0', content: '2026/8/26 15:42:11' },

// ④ 模型返回:基于工具结果组织出的最终回答
{ role: 'assistant', content: '现在是 2026 年 8 月 26 日下午 3 点 42 分。' },

4.4 role:history 里每一句话的身份

messages 里每一条消息都有一个 role 字段,OpenAI Chat Completions 一共有四种,Day 2 已用到三种:

role谁写的含义本项目何时出现
system我们设定模型行为与规则的指令,优先级最高还没用,项目指令注入时会用到
user我们调用方输入的原话每次用户提问入队(即上面的 ①)
assistant模型模型的输出:普通文本,或携带 tool_calls 的「我要用工具」声明②(带工具调用)、④(最终回答)
tool工具(经我们回传)某次工具调用的执行结果,必须用 tool_call_id 对上 assistant 发起的调用③(工具真实输出)

两个 role 容易混淆:

  • system 用来提供高优先级指令,这一天还没有使用。
  • tool 消息不给用户看,它是模型与工具之间的「一问一答」:assistant 发起(带 id),tool 应答(带回同一个 tool_call_id)。若对不上 id,接口直接 400——这就是坑二里要补 id 的原因。

4.5 color.ts —— 复用 Day 1 的终端着色

Day 2 直接复用 Day 1 的 color.tspaint / out / err + 颜色表 C),不另写一份。唯一新增的着色需求是工具调用进度行要醒目:模型调用工具时 chat.ts 会 yield 一段 [调用工具 ...],这一行用黄色(tool)而非模型正文的绿色(model)。

index.ts 里按行首判断即可,仍是同一个 out

// day2/index.ts
for await (const delta of chat.streamReply(line)) {
    out(delta.startsWith('\n[调用工具') ? 'tool' : 'model', delta);
}

效果即本节开头展示的那段:工具调用进度行染黄,模型正文染绿。

5. 验证

  • npm run typecheck:确认类型检查通过。
  • npm run dev -- day2/index.ts:启动后询问“现在几点”。
  • 确认终端先显示 get_current_time 的真实结果,再看到模型根据该结果回答。

6. 没做什么

get_current_time 不会触碰本地数据,因此这一天还没有处理执行权限。它只负责验证工具调用循环,模型暂时不能操作本地机器。

7. 下一步

工具循环已经能连接模型与外部函数。下一步接入能操作本地环境的工具,同时考虑执行前确认和资源限制。

如果文章对你有帮助,欢迎赞赏支持 ☕

支付宝
微信

评论