101 lines
5.1 KiB
Markdown
101 lines
5.1 KiB
Markdown
---
|
||
id: ai.function-calling.tool-loop
|
||
title: 工具调用原理:工具定义、调用循环、参数校验、并行调用和错误回传
|
||
type: lesson
|
||
domain: ai
|
||
stack: [function-calling]
|
||
tags: [Function Calling, Tool Calling, JSON Schema, 调用循环, 参数校验]
|
||
level: intermediate
|
||
prerequisites: [python.python.typing-errors-testing]
|
||
est_hours: 3
|
||
related: [ai.mcp.index, ai.langgraph.state-graph, ai.function-calling.interview]
|
||
week: [10]
|
||
order: 1
|
||
---
|
||
|
||
# 工具调用原理:工具定义、调用循环、参数校验、并行调用和错误回传
|
||
|
||
> 各家模型的工具调用格式大同小异,本课以 OpenAI Chat Completions 的格式为例,字段以各家官方文档为准:https://platform.openai.com/docs/guides/function-calling
|
||
|
||
## 解释说明
|
||
|
||
**模型本身不会执行任何代码。** 工具调用(Function Calling / Tool Calling)的流程是:
|
||
|
||
1. 你把工具的名字、描述、参数的 JSON Schema 和对话一起发给模型;
|
||
2. 模型判断需要工具时,返回 `tool_calls`(工具名 + JSON 字符串参数),而不是文本答案;
|
||
3. **你的代码**校验参数、检查权限、执行工具;
|
||
4. 把结果作为 `role: "tool"` 的消息(带 `tool_call_id`)加回对话,再次请求模型;
|
||
5. 重复,直到模型返回普通文本答案,或者达到最大步数。
|
||
|
||
这就是所有 Agent 框架的核心循环(ReAct 的工程实现)。LangChain 的 `create_agent`、LangGraph 的工具节点、MCP 的工具,最后都落到这个循环。
|
||
|
||
### 工程上要处理的事
|
||
|
||
| 问题 | 做法 |
|
||
| --- | --- |
|
||
| 参数不可信(格式错、越权、注入) | pydantic 校验;权限在工具内部按**当前登录用户**校验,不能信任模型传的用户 ID |
|
||
| 一次返回多个调用(并行调用) | 逐个(或并发)执行,每个结果带对应的 `tool_call_id` 回传 |
|
||
| 工具执行失败 | 把错误信息作为工具结果回传,让模型修正;不要直接抛异常中断 |
|
||
| 有副作用的操作(退款、发邮件、删数据) | 用户确认 / 人工审批;幂等键;审计日志 |
|
||
| 无限循环 | 最大步数,超过后转人工 |
|
||
| 工具太多 | 工具描述占用上下文、模型选错;按场景动态选择工具子集(见 [Agent 设计](../agent-design/index.md)) |
|
||
|
||
### 工具描述怎么写
|
||
|
||
- 名字动词开头、含义明确:`get_order`、`create_refund`。
|
||
- 描述写清楚**什么时候用、什么时候不用**、返回什么。
|
||
- 参数加 `description`、枚举、格式约束(`pattern`、`minimum`)。模型会读这些。
|
||
|
||
## 案例
|
||
|
||
售后助手:用户说“订单 1001 我要退款”。模型第一次传了 `order_id: "1001"`,校验失败(格式应为 `A1001`),错误回传后模型改成 `A1001` 查询成功;随后调用退款工具,因为是有副作用的操作,先请求用户确认;最后回复用户。
|
||
|
||
## 代码示例
|
||
|
||
完整可运行版本(带 5 个测试):[examples/tool-loop](examples/tool-loop/)。核心循环:
|
||
|
||
```python
|
||
for step in range(max_steps):
|
||
msg = model.chat(messages, tool_schemas)
|
||
messages.append(msg)
|
||
if not msg.get("tool_calls"):
|
||
return msg["content"] # 模型给出最终答案
|
||
for c in msg["tool_calls"]:
|
||
tool = TOOLS.get(c["function"]["name"])
|
||
try:
|
||
args = tool.args_model.model_validate_json(c["function"]["arguments"])
|
||
if tool.needs_confirm and not confirm(tool.name, args.model_dump()):
|
||
result = {"error": "用户拒绝执行该操作"}
|
||
else:
|
||
result = tool.fn(args, ctx) # ctx 里是当前登录用户,不来自模型
|
||
except Exception as e:
|
||
result = {"error": f"参数错误:{e}"} # 错误回传给模型
|
||
messages.append({"role": "tool", "tool_call_id": c["id"],
|
||
"content": json.dumps(result, ensure_ascii=False)})
|
||
return "步骤过多,已转人工处理。"
|
||
```
|
||
|
||
运行输出:
|
||
|
||
```text
|
||
调用 get_order({"order_id": "1001"}) -> {'error': "参数错误:1 validation error for GetOrderArgs ..."}
|
||
调用 get_order({"order_id": "A1001"}) -> {'order_id': 'A1001', 'user': 'u1', 'paid': 199.0, 'status': 'delivered'}
|
||
调用 refund({"order_id": "A1001", "amount": 199}) -> {'ok': True, 'refunded': 199.0}
|
||
回答: 已为订单 A1001 退款 199 元。 步数: 4
|
||
```
|
||
|
||
## 易错点
|
||
|
||
- 以为模型会执行工具:执行、权限、错误处理全是你的代码。
|
||
- `arguments` 是 JSON **字符串**,要解析和校验,可能不是合法 JSON。
|
||
- 回传结果时漏了 `tool_call_id`,或者顺序和数量对不上,接口报错。
|
||
- 工具结果太长(整张表、整篇文档)塞满上下文:只返回需要的字段,必要时分页。
|
||
- 工具结果里包含的文本也可能是提示词注入(例如网页内容),要当作数据而不是指令。
|
||
- 让模型决定“是否需要确认”:确认规则应写在代码里。
|
||
|
||
## 面试常问
|
||
|
||
- Function Calling 的完整流程?模型在其中做了什么、没做什么?
|
||
- 模型返回的参数不合法怎么办?
|
||
- 有副作用的工具怎么保证安全?
|