Skip to content

第3章 工具调用与单智能体

3.1 问题场景

只依赖模型内知识会很快遇到边界:无法访问实时数据、无法执行外部动作、无法形成任务闭环。 工具调用是单智能体从“会回答”走向“会做事”的关键一步,也是前端工程师最容易做出第一个可展示项目的切入点。

很多前端同学刚开始做 Agent 时,会把它写成“聊天框 + 一段提示词(prompt)+ 一个接口请求”。这类 Demo 可以演示,但很难经受真实业务追问:

  • 如果回答依赖实时订单、天气、库存、工单或日历数据怎么办。
  • 如果用户的问题需要先查数据,再生成解释,最后再给出建议怎么办。
  • 如果工具失败、超时、权限不足,前端怎么展示才不会让用户觉得系统坏了。

前端迁移提示

你原来熟悉的很多能力其实都可以直接迁移到这一章:

  • 组件状态管理 -> Agent 执行状态管理。
  • 接口契约设计 -> 工具 schema(结构契约)与响应结构设计。
  • 异步请求和错误处理 -> 工具调用超时、重试和兜底。
  • 页面交互反馈 -> thinking / calling_tool / completed / failed 状态展示。

最小项目目标

本章建议先完成一个“单 Agent 工具调用最小闭环”项目,例如:

  • 查询天气并生成出行建议。
  • 查询订单状态并生成客服回复。
  • 查询知识库文档并返回结构化答案。

参考入口:

3.2 核心原理

工具调用的关键不是“把模型接上工具”这么简单,而是把模型决策、工具执行、结果消费、状态反馈和安全边界串成一个可控闭环。 如果闭环里任意一层模糊,系统就会在调试、体验或上线阶段不断出问题。

一、能力分层:模型负责决策,工具负责执行

单智能体最常见的错误,是让模型既负责理解问题,又负责凭空生成工具结果。 更稳妥的方式是做能力分层:

  • 模型层:判断要不要调用工具、调用哪个工具、如何组织最终回答。
  • 工具层:负责实际查数据、执行动作、返回结构化结果。
  • 服务层:负责串联模型与工具、处理异常、统一输出格式。
  • 前端层:负责状态展示、引用或结果展示、错误兜底与人工确认。

二、接口契约:工具必须像一个靠谱 API

每个工具都要像正式接口一样具备清晰契约,而不是“先写个函数试试”。 一个最小工具契约至少应包含:

  • 工具名称。
  • 工具描述。
  • 输入参数类型与必填项。
  • 返回结果结构。
  • 失败时的错误码或错误类型。
  • 是否有副作用、是否需要确认。

例如天气查询工具的最小 schema(结构契约)可以定义为:

json
{
  "name": "get_weather",
  "description": "查询指定城市天气",
  "input_schema": {
    "type": "object",
    "properties": {
      "city": {"type": "string"}
    },
    "required": ["city"]
  }
}

三、状态机思维:不要把 Agent 当成一次性请求

对于前端工程师来说,这一层尤其重要。 单 Agent 的执行过程至少应该有几个明确状态:

  • idle:尚未开始。
  • thinking:模型正在理解问题。
  • calling_tool:系统正在执行外部工具。
  • completed:工具完成并返回最终结果。
  • failed:工具异常、结果不可用或需要人工介入。

如果没有这层状态机,前端最终往往只剩一个模糊的加载态,用户很难理解系统现在在做什么。

四、结构化输出:让前端和工作流都能稳定消费

单 Agent 不只要“说得像人”,更要“让系统能消费”。 因此推荐统一输出结构,例如:

json
{
  "answer": "上海当前多云,气温约 24°C,建议轻便出行。",
  "tool_used": "get_weather",
  "tool_result": {
    "city": "上海",
    "condition": "多云",
    "temperature_c": 24
  },
  "need_human_review": false
}

你可以直接复用:

  • docs/examples/assets/samples/example-01-success-response.json
  • docs/examples/assets/samples/example-01-error-response.json

五、安全边界:高风险工具不能像普通查询一样处理

查询类工具和执行类工具不能一视同仁。 例如查天气和发通知、删记录、提审批,风险完全不同。 设计时至少要区分:

  • 只读工具。
  • 写入型工具。
  • 高风险工具。

对于高风险工具,建议加:

  • 白名单。
  • 权限校验。
  • 审计日志。
  • 用户确认。
  • 超时与回退策略。

3.3 实操步骤

推荐做法不是先接很多工具,而是先做一个 1 个工具也能跑通的闭环。 先把“模型决策 -> 工具执行 -> 前端展示 -> 错误兜底”做实,再往上叠复杂度。

步骤 1:先定义一个单一业务目标

推荐选择一个最小但完整的问题,例如:

  • 帮我查天气并给出建议。
  • 帮我查订单状态并生成一句客服回复。
  • 帮我查制度条款并返回一个带来源的答案。

这个目标要满足两个条件:

  • 需要调用外部工具才能完成。
  • 最终结果能被前端稳定展示。

步骤 2:只设计 1 到 2 个工具接口

不要一开始就接 5 个以上工具。 第一版最推荐的工具应该是:

  • 输入单一。
  • 返回结构清晰。
  • 风险较低。
  • 失败后容易兜底。

如果需要一个最小工具骨架,可参考:

步骤 3:为工具写清楚契约

至少明确以下字段:

  • 工具名和用途。
  • 输入参数 schema(结构定义)。
  • 返回结构。
  • 失败类型。
  • 前端需要展示哪些字段。

如果你跳过这一步,后面最常见的问题就是:

  • 模型传错参数。
  • 工具返回格式漂移。
  • 前端不知道怎么展示结果。

步骤 4:实现最小执行闭环

最小闭环建议包括:

  1. 用户输入问题。
  2. 模型判断是否需要调用工具。
  3. 服务层执行工具。
  4. 工具返回结构化结果。
  5. 服务层组装最终结构化响应。
  6. 前端按状态和结果结构展示。

步骤 5:补失败兜底

必须至少覆盖以下失败场景:

  • 工具超时。
  • 工具返回空结果。
  • 工具参数错误。
  • 工具权限不足。

失败时不要只返回“出错了”,而要尽量给出:

  • 当前失败原因。
  • 是否可重试。
  • 是否建议人工介入。

步骤 6:补前端状态与证据材料

前端建议至少展示:

  • 当前状态。
  • 回答正文。
  • 调用的工具名。
  • 工具结果摘要。
  • 错误提示或风险提示。

这一步完成后,建议同步保留证据材料:

  • docs/examples/assets/screenshots/README.md
  • docs/examples/assets/samples/example-01-success-response.json
  • docs/examples/assets/samples/example-01-error-response.json

步骤 7:把最小骨架映射回章节能力

如果你已经把这个例子做通,就可以开始建立能力映射:

  • 这就是第 3 章最小可展示项目。
  • 后面加检索,就是第 4-5 章的 RAG 项目。
  • 后面加节点状态和人工确认,就是第 6 章工作流项目。

代码实战:可运行的 TypeScript 示例

前面的步骤讲了"做什么",这部分给出"怎么写"。 所有代码基于 OpenAI SDK,可以直接复制到本地跑通。如果你更熟悉其他模型提供商,只需替换 client 初始化部分,核心模式完全一致。

示例 1:Tool Calling 最小闭环

这是最核心的代码。对应步骤 2 到步骤 4 的完整实现:定义工具 → 实现工具 → 跑通 Agent 循环。

typescript
// tool-calling-agent.ts
import OpenAI from 'openai'

const client = new OpenAI()

// 定义工具 — 类似前端定义 API 接口契约
const tools: OpenAI.ChatCompletionTool[] = [
  {
    type: 'function',
    function: {
      name: 'get_weather',
      description: '获取指定城市的天气信息',
      parameters: {
        type: 'object',
        properties: {
          city: { type: 'string', description: '城市名称,如 北京' },
          unit: { type: 'string', enum: ['celsius', 'fahrenheit'], description: '温度单位' }
        },
        required: ['city']
      }
    }
  },
  {
    type: 'function',
    function: {
      name: 'search_docs',
      description: '搜索技术文档',
      parameters: {
        type: 'object',
        properties: {
          query: { type: 'string', description: '搜索关键词' },
          language: { type: 'string', enum: ['zh', 'en'], description: '文档语言' }
        },
        required: ['query']
      }
    }
  }
]

// 工具实现 — 类似前端的 service 层
async function executeFunction(name: string, args: Record<string, unknown>): Promise<string> {
  switch (name) {
    case 'get_weather':
      // 实际项目中调用天气 API
      return JSON.stringify({ city: args.city, temp: 22, condition: '晴', humidity: 45 })
    case 'search_docs':
      return JSON.stringify({ results: [{ title: `${args.query} 文档`, url: 'https://...' }] })
    default:
      return JSON.stringify({ error: `未知工具: ${name}` })
  }
}

// Agent 循环 — 模型决策 → 工具执行 → 结果回传 → 继续或结束
async function runAgent(userMessage: string) {
  const messages: OpenAI.ChatCompletionMessageParam[] = [
    { role: 'system', content: '你是一个技术助手,可以查天气和搜索文档。优先调用工具获取真实数据。' },
    { role: 'user', content: userMessage }
  ]

  let maxTurns = 5 // 防止无限循环
  while (maxTurns-- > 0) {
    const response = await client.chat.completions.create({
      model: 'gpt-4o-mini',
      messages,
      tools,
    })

    const choice = response.choices[0]
    messages.push(choice.message)

    // 没有工具调用 → 最终回答
    if (!choice.message.tool_calls?.length) {
      console.log('Agent:', choice.message.content)
      return
    }

    // 执行工具并回传结果
    for (const toolCall of choice.message.tool_calls) {
      const args = JSON.parse(toolCall.function.arguments)
      console.log(`[调用工具] ${toolCall.function.name}(${JSON.stringify(args)})`)
      const result = await executeFunction(toolCall.function.name, args)
      messages.push({ role: 'tool', tool_call_id: toolCall.id, content: result })
    }
  }
}

runAgent('北京今天天气怎么样?顺便帮我搜一下 React Server Components 的文档')

前端知识桥梁:上面的 tools 数组相当于 Swagger/OpenAPI 文档,定义了可调用接口的契约。Agent 循环的模式和 Redux saga 或 useEffect 依赖循环类似:响应 → 判断 → 执行 → 更新状态 → 再次判断。

示例 2:前端流式输出处理(SSE)

前端展示 Agent 回复时,流式输出比等整段文字体验好得多。原理和你熟悉的 EventSource / ReadableStream 一样。

typescript
// stream-agent.ts — 流式输出,适合前端实时展示
import OpenAI from 'openai'

const client = new OpenAI()

async function streamChat(userMessage: string) {
  const stream = await client.chat.completions.create({
    model: 'gpt-4o-mini',
    messages: [{ role: 'user', content: userMessage }],
    stream: true,
  })

  process.stdout.write('Agent: ')
  for await (const chunk of stream) {
    const content = chunk.choices[0]?.delta?.content
    if (content) process.stdout.write(content)
  }
  console.log()
}

streamChat('用前端开发者能理解的方式,解释 Tool Calling 的工作原理')

示例 3:React 组件中集成 Agent 流式输出

如果你已经有一个后端 Agent API(比如用示例 1 的逻辑搭一个 /api/agent 接口),前端这样接:

tsx
// AgentChat.tsx — React 组件中集成 Agent 流式输出
'use client'
import { useState, useCallback } from 'react'

export function AgentChat() {
  const [messages, setMessages] = useState<{ role: string; content: string }[]>([])
  const [input, setInput] = useState('')
  const [loading, setLoading] = useState(false)

  const sendMessage = useCallback(async () => {
    if (!input.trim() || loading) return
    const userMsg = { role: 'user', content: input }
    setMessages(prev => [...prev, userMsg])
    setInput('')
    setLoading(true)

    // 调用你的 Agent API(需要自己搭建后端)
    const res = await fetch('/api/agent', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ messages: [...messages, userMsg] }),
    })

    // 处理 SSE 流式响应
    const reader = res.body!.getReader()
    const decoder = new TextDecoder()
    let assistantContent = ''
    setMessages(prev => [...prev, { role: 'assistant', content: '' }])

    while (true) {
      const { done, value } = await reader.read()
      if (done) break
      assistantContent += decoder.decode(value, { stream: true })
      setMessages(prev => {
        const updated = [...prev]
        updated[updated.length - 1] = { role: 'assistant', content: assistantContent }
        return updated
      })
    }
    setLoading(false)
  }, [input, messages, loading])

  return (
    <div style={{ maxWidth: 600, margin: '0 auto', padding: 20 }}>
      <div style={{ minHeight: 300, border: '1px solid #e5e7eb', borderRadius: 12, padding: 16, marginBottom: 12 }}>
        {messages.map((m, i) => (
          <div key={i} style={{ marginBottom: 12, textAlign: m.role === 'user' ? 'right' : 'left' }}>
            <span style={{
              display: 'inline-block', padding: '8px 14px', borderRadius: 12,
              background: m.role === 'user' ? '#2563eb' : '#f3f4f6',
              color: m.role === 'user' ? '#fff' : '#111',
            }}>
              {m.content || '思考中...'}
            </span>
          </div>
        ))}
      </div>
      <div style={{ display: 'flex', gap: 8 }}>
        <input
          value={input}
          onChange={e => setInput(e.target.value)}
          onKeyDown={e => e.key === 'Enter' && sendMessage()}
          placeholder="输入消息..."
          style={{ flex: 1, padding: '10px 14px', borderRadius: 10, border: '1px solid #d1d5db' }}
        />
        <button
          onClick={sendMessage}
          disabled={loading}
          style={{ padding: '10px 20px', borderRadius: 10, background: '#2563eb', color: '#fff', border: 'none', cursor: 'pointer' }}
        >
          发送
        </button>
      </div>
    </div>
  )
}

前端知识桥梁ReadableStream + TextDecoder 是浏览器标准 API,你在做文件上传进度、SSE 推送时大概率用过。这里唯一的区别是数据源从后端接口变成了 Agent API。

示例 4:工具调用的错误处理与重试

对应步骤 5(补失败兜底)。工具调用和前端 API 请求一样,必须考虑超时、异常和重试。

typescript
// 工具调用的防御性编程 — 类似前端 API 错误处理
async function safeExecute(name: string, args: Record<string, unknown>): Promise<string> {
  const maxRetries = 2
  for (let i = 0; i <= maxRetries; i++) {
    try {
      const result = await executeFunction(name, args)
      return result
    } catch (err) {
      if (i === maxRetries) {
        return JSON.stringify({ error: `工具 ${name} 调用失败: ${(err as Error).message}` })
      }
      await new Promise(r => setTimeout(r, 1000 * (i + 1))) // 指数退避
    }
  }
  return JSON.stringify({ error: '未知错误' })
}

前端知识桥梁:这段重试逻辑和你在 Axios 拦截器或 React Query 的 retry 配置里做的事情完全一样——失败后指数退避重试,最终兜底返回错误信息而不是让整个流程崩掉。

概念映射速查

如果你已经跑通了上面的代码,可以用这张表把 Agent 开发概念和前端已有知识对应起来:

Agent 概念前端类比说明
Tool Calling后端微服务的 RPC 调用模型是调用方,工具是被调用的服务
tools 数组Swagger / OpenAPI 文档定义可调用接口的输入输出契约
Agent 循环Redux saga / useEffect 依赖循环响应 → 判断 → 执行 → 更新 → 再判断
流式输出EventSource / ReadableStream前端开发者已有的流式数据处理基础
工具重试Axios 拦截器 / React Query retry指数退避 + 最终兜底
Agent 状态机组件状态管理(useState / useReducer)idle → thinking → calling_tool → completed / failed

3.4 常见坑

坑 1:工具接口定义含糊

如果工具名和参数设计模糊,模型很容易:

  • 误选工具。
  • 传错参数。
  • 返回结果难以消费。

坑 2:一开始接太多工具

工具越多,调试难度、冲突概率和权限治理成本都会快速上升。 建议先把 1 到 2 个高价值工具打稳。

坑 3:没有统一输出结构

很多 Demo 初期是自由文本,后期前端要展示、工作流要消费时才发现根本不好接。 这时返工成本会很高。

坑 4:失败场景没有设计

只要出现工具超时或空结果,整个对话就断掉,是单 Agent 最常见的问题。 你必须先定义失败返回,而不是等线上出错后再补。

坑 5:前端状态太粗

如果页面只有一个 loading,用户无法知道系统是在理解问题、调用工具,还是已经失败卡住。 状态设计太粗,会直接伤害可信度。

坑 6:把高风险动作和查询动作混在一起

查询天气、查订单和“自动发送消息”“自动修改记录”不应使用同样的默认策略。 后者必须更早引入权限、确认和审计。

3.5 验证方式

单 Agent 做完之后,不要只问“能不能跑”,而要问“有没有达到一个最小可交付标准”。

一、核心流程成功率

建议先用 30 条测试请求做基线验证:

  • 正常请求成功率 >= 90%。
  • 工具参数错误率可控。
  • 工具返回结构始终稳定。

二、失败场景可解释性

至少验证:

  • 工具失败时,用户是否能看到明确提示。
  • 前端状态是否能正确进入 failedneed_human_review
  • 是否可以追踪失败原因。

三、调用记录可追溯

每次调用至少应能追溯到:

  • 用户输入。
  • 调用了哪个工具。
  • 请求参数是什么。
  • 返回结果是什么。
  • 耗时和错误信息是什么。

四、前端体验是否站得住

从用户体验角度再检查:

  • 中间状态是否可见。
  • 结果是否可理解。
  • 工具结果是否展示得足够清楚。
  • 出错时是否有下一步动作提示。

五、证据材料

建议最少保留这些材料,方便后续做作品集:

  • 成功输出样本:docs/examples/assets/samples/example-01-success-response.json
  • 失败输出样本:docs/examples/assets/samples/example-01-error-response.json
  • 截图清单:docs/examples/assets/screenshots/README.md
  • 章节片段索引

3.6 面试表达

你可以把这一章的能力总结成一句话: 我不是只把模型接上了一个工具,而是把“模型决策、工具执行、结构化输出、前端状态展示和失败兜底”做成了一个最小可交付闭环。

实战案例:订单查询客服助手

  • 背景:客服处理订单查询依赖多系统切换,响应慢且易出错。
  • 动作:接入订单查询和状态解释工具,统一输出结构,并补充超时、空结果与权限不足兜底。
  • 结果:查询流程可以在单轮内闭环,前端能够明确展示当前状态、工具结果和失败提示。

推荐答题框架

如果面试官问“你在单 Agent 项目里到底做了什么”,可以按下面顺序回答:

  1. 先讲业务问题,而不是先讲模型。
  2. 再讲为什么需要工具调用,而不是只靠模型回答。
  3. 再讲你如何设计工具 schema(结构契约)和状态机。
  4. 最后讲验证方式:成功率、失败兜底和前端体验。

可以直接挂到作品集的证据

  • 示例骨架
  • 成功样本:docs/examples/assets/samples/example-01-success-response.json
  • 失败样本:docs/examples/assets/samples/example-01-error-response.json
  • 截图清单:docs/examples/assets/screenshots/README.md

3.7 练习任务

  1. 选一个最小业务目标,写出“用户输入 -> 工具调用 -> 最终回答”的完整链路说明。
  2. 定义 2 个工具接口,并分别写清楚输入 schema、返回结构与失败类型。
  3. 为前端定义一组最小状态枚举,至少覆盖 idle / thinking / calling_tool / completed / failed
  4. 设计至少 2 条失败兜底回复,并说明前端分别应该展示什么。
  5. 结合 示例1 单Agent工具调用,改出一个属于你自己的最小 Agent 场景。

3.8 验收清单

  • 任务完成率 >= 85%
  • 关键指标达标率 >= 90%
  • 异常场景通过率 >= 90%
  • 至少完成 1 个最小工具调用闭环,并能解释每层职责。
  • 至少保留 1 份成功样本、1 份失败样本和 1 组截图占位清单。
  • 至少能用 2 分钟讲清楚“为什么这个系统不是普通聊天框,而是单智能体最小可交付闭环”。

3.9 💡 本地模型的 Tool Calling — Ollama + Qwen3 实战

🏠 自托管替代方案

本章的 Tool Calling 示例基于 OpenAI API,但多个开源模型已原生支持 Function Calling。以下介绍如何用本地模型实现相同功能。

支持 Tool Calling 的开源模型

模型Tool Calling 支持推荐理由
Qwen3 (8B/72B)✅ 原生支持中文场景常用,Apache 2.0
Llama 3.1+ (8B/70B)✅ 原生支持生态成熟,社区资料丰富
Mistral Small 3.x✅ 原生支持官方文档有 function calling 能力说明
Phi-4 / Phi-4-mini⚠️ 需区分型号phi4-mini 标注 tools,phi4 建议按版本实测

代码改造

基于本章的 Tool Calling Agent 代码,只需修改客户端初始化:

typescript
import OpenAI from 'openai';

const client = new OpenAI({
  baseURL: 'http://localhost:11434/v1',
  apiKey: 'ollama',
});

// tools 定义在多数场景与 OpenAI 兼容;不同模型建议保留参数校验与回退逻辑
const tools = [
  {
    type: 'function' as const,
    function: {
      name: 'get_weather',
      description: '获取指定城市的天气',
      parameters: {
        type: 'object',
        properties: {
          city: { type: 'string', description: '城市名称' }
        },
        required: ['city']
      }
    }
  }
];

const response = await client.chat.completions.create({
  model: 'qwen3:8b',      // 使用本地 Qwen3
  messages: [{ role: 'user', content: '北京今天天气怎么样?' }],
  tools,
  tool_choice: 'auto',
});

本地 Tool Calling 注意事项

  1. 参数解析稳定性:小模型(<14B)偶尔会生成格式错误的 JSON,建议加 try-catch + 重试
  2. temperature 建议:Tool Calling 场景建议 temperature 设为 0-0.3,提高格式稳定性
  3. 并行调用:本地小模型对并行 Tool Calling(一次返回多个工具调用)支持较弱,建议逐个调用
  4. 模型选择:如果 Tool Calling 是核心需求,优先选 Qwen3 或 Mistral

🖥️ 前端迁移提示:本地 Tool Calling 的可靠性差异,类似于不同浏览器对 Web API 的支持程度不同——需要做兼容性处理和降级方案。

深入阅读

更多本地模型选型和 Tool Calling 优化技巧,请阅读 专题 11:自托管 AI Agent 全栈指南