主题
第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.jsondocs/examples/assets/samples/example-01-error-response.json
五、安全边界:高风险工具不能像普通查询一样处理
查询类工具和执行类工具不能一视同仁。 例如查天气和发通知、删记录、提审批,风险完全不同。 设计时至少要区分:
- 只读工具。
- 写入型工具。
- 高风险工具。
对于高风险工具,建议加:
- 白名单。
- 权限校验。
- 审计日志。
- 用户确认。
- 超时与回退策略。
3.3 实操步骤
推荐做法不是先接很多工具,而是先做一个 1 个工具也能跑通的闭环。 先把“模型决策 -> 工具执行 -> 前端展示 -> 错误兜底”做实,再往上叠复杂度。
步骤 1:先定义一个单一业务目标
推荐选择一个最小但完整的问题,例如:
- 帮我查天气并给出建议。
- 帮我查订单状态并生成一句客服回复。
- 帮我查制度条款并返回一个带来源的答案。
这个目标要满足两个条件:
- 需要调用外部工具才能完成。
- 最终结果能被前端稳定展示。
步骤 2:只设计 1 到 2 个工具接口
不要一开始就接 5 个以上工具。 第一版最推荐的工具应该是:
- 输入单一。
- 返回结构清晰。
- 风险较低。
- 失败后容易兜底。
如果需要一个最小工具骨架,可参考:
步骤 3:为工具写清楚契约
至少明确以下字段:
- 工具名和用途。
- 输入参数 schema(结构定义)。
- 返回结构。
- 失败类型。
- 前端需要展示哪些字段。
如果你跳过这一步,后面最常见的问题就是:
- 模型传错参数。
- 工具返回格式漂移。
- 前端不知道怎么展示结果。
步骤 4:实现最小执行闭环
最小闭环建议包括:
- 用户输入问题。
- 模型判断是否需要调用工具。
- 服务层执行工具。
- 工具返回结构化结果。
- 服务层组装最终结构化响应。
- 前端按状态和结果结构展示。
步骤 5:补失败兜底
必须至少覆盖以下失败场景:
- 工具超时。
- 工具返回空结果。
- 工具参数错误。
- 工具权限不足。
失败时不要只返回“出错了”,而要尽量给出:
- 当前失败原因。
- 是否可重试。
- 是否建议人工介入。
步骤 6:补前端状态与证据材料
前端建议至少展示:
- 当前状态。
- 回答正文。
- 调用的工具名。
- 工具结果摘要。
- 错误提示或风险提示。
这一步完成后,建议同步保留证据材料:
docs/examples/assets/screenshots/README.mddocs/examples/assets/samples/example-01-success-response.jsondocs/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%。
- 工具参数错误率可控。
- 工具返回结构始终稳定。
二、失败场景可解释性
至少验证:
- 工具失败时,用户是否能看到明确提示。
- 前端状态是否能正确进入
failed或need_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 项目里到底做了什么”,可以按下面顺序回答:
- 先讲业务问题,而不是先讲模型。
- 再讲为什么需要工具调用,而不是只靠模型回答。
- 再讲你如何设计工具 schema(结构契约)和状态机。
- 最后讲验证方式:成功率、失败兜底和前端体验。
可以直接挂到作品集的证据
- 示例骨架
- 成功样本:
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 练习任务
- 选一个最小业务目标,写出“用户输入 -> 工具调用 -> 最终回答”的完整链路说明。
- 定义 2 个工具接口,并分别写清楚输入 schema、返回结构与失败类型。
- 为前端定义一组最小状态枚举,至少覆盖
idle / thinking / calling_tool / completed / failed。 - 设计至少 2 条失败兜底回复,并说明前端分别应该展示什么。
- 结合 示例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 注意事项
- 参数解析稳定性:小模型(<14B)偶尔会生成格式错误的 JSON,建议加 try-catch + 重试
- temperature 建议:Tool Calling 场景建议 temperature 设为 0-0.3,提高格式稳定性
- 并行调用:本地小模型对并行 Tool Calling(一次返回多个工具调用)支持较弱,建议逐个调用
- 模型选择:如果 Tool Calling 是核心需求,优先选 Qwen3 或 Mistral
🖥️ 前端迁移提示:本地 Tool Calling 的可靠性差异,类似于不同浏览器对 Web API 的支持程度不同——需要做兼容性处理和降级方案。
深入阅读
更多本地模型选型和 Tool Calling 优化技巧,请阅读 专题 11:自托管 AI Agent 全栈指南。