在 GitHub 上刷智能体(Agent)项目的同学,最近应该有个很直观的感受:那些只做“聊天”的 Agent 项目,热度在下降;而接上工具调用、能真正操作外部系统的项目,Star 涨得很快。这个变化背后其实是一个共识正在形成——智能体如果只会“说”,那它本质上还是一个套了提示词外壳的聊天机器人;只有当它有了“手”(工具调用)和“回执”(任务执行反馈),才真正从“建议者”变成“执行者”。
很多刚接触 Agent 的开发者,早期都会被“大模型会自己调用工具”这一点惊艳到,以为只要在提示词里写一句“你可以调用工具”,模型就会自动完成所有事情。真正动手做项目后才发现,问题根本不是“它会不会调用”,而是“调用之后发生了什么”、“它怎么知道自己调用成功了”、“工具执行出错时它该怎么处理”。这些问题全部指向同一个关键词:回执。
这篇文章想聊清楚三件事:第一,为什么“会说”只是智能体落地的最低门槛;第二,工具调用和回执机制在工程上到底是怎么设计的;第三,作为一个普通开发者,怎么在你的项目里把“手”和“回执”接起来。文章会用完整的代码示例来说明,不涉及复杂的分布式架构,尽量让一个只有 Python 基础的读者也能跟下来。
1. 这篇文章真正要解决的问题
如果你去看 GitHub 上智能体相关的热门项目,会发现一个规律:最早火起来的那批项目,靠的是“大模型对话 + 通用知识库”;而最近活跃度明显更高的项目,几乎都具备“任务型”特征——它们能查天气、能订日历、能操作数据库、能调接口、能写文件,甚至能根据上一个步骤的结果自动调整下一个动作。
换句话说,智能体的能力壁垒已经从“脑力”转移到了“手力”和“反馈力”。而要拥有“手力”和“反馈力”,你绕不开两个技术点:
- 工具调用(Tool Calling / Function Calling):让大模型在对话过程中,输出一个结构化的工具调用请求,而不是简单输出一句“我建议你去看天气预报”。
- 执行回执(Tool Result / Callback):工具执行完以后,把结构化结果(成功还是失败、数据是什么、耗时多久、有没有异常)反馈给大模型,让大模型能在这个结果的基础上继续推理。
如果只做了第一点,没做第二点,你会遇到很典型的问题:模型调用了天气接口,但天气接口返回了一个错误码,模型并不知道,还在那里一本正经地跟用户说“明天多云”;模型调用了一个写文件的工具,写了一半抛异常了,但模型以为文件已经写好了,继续执行后续步骤。这些问题的本质,都是缺少回执闭环。
因此,这篇文章主要面向以下几类读者:
- 正在用大模型 API 做 AI 应用,但产品还停留在“聊天机器人”阶段的开发者;
- 在 Dify、Coze 等低代码平台上搭建智能体,想理解平台背后工具节点原理的配置者;
- 正在做企业级智能体集成,需要把 Agent 接入内部系统、数据库或审批流的后端工程师。
读完这篇文章,你能建立一个清晰的判断框架:一个智能体项目,到底哪些能力是“包装”,哪些能力是“地基”。同时,你能照着文章里的代码,把一个带工具调用和回执处理的最小智能体跑起来。
2. 智能体的核心概念:模型、工具、回执三者之间的关系
在讨论代码之前,先把几个基础概念讲透。很多看似高深的问题,其实都是这几个基础概念没有理清。
2.1 大模型在 Agent 里的角色不是“执行者”,而是“调度者”
我们最容易犯的一个错误,是让大模型什么事都干。让模型写文件、让模型查数据库、让模型直接发起网络请求——听起来很智能,但工程上根本不靠谱。大模型的强项是理解意图、拆分任务、选择策略,而不是稳定执行确定性操作。一个加法都可能在浮点数问题上出错的大模型,你让它去操作生产数据库,风险可想而知。
所以,在成熟的项目里,大模型的角色被限定为“调度者”:它负责判断用户想要什么,然后决定调用哪个工具、传什么参数。真正的执行动作,由一段确定性的代码完成。
2.2 工具(Tool)就是 Agent 的“手”
工具是可以用代码实现的函数、API、脚本或命令行。比如:
- 查询天气:调用一个天气服务的 API;
- 创建日历事件:调用日历服务的接口;
- 搜索知识库:用向量检索查数据库;
- 执行 SQL:针对业务数据库运行查询语句。
每个工具都需要向大模型提供一份“说明书”,通常是一个 JSON Schema,包括工具名称、功能描述、参数结构。大模型会根据这份说明书,决定是否调用这个工具以及传入什么参数。说明书写得清不清楚,直接决定了大模型调用工具的准确率。
2.3 回执(Tool Result)是连接“执行”和“推理”的闭环
工具执行完以后,返回的结果不会直接展示给用户,而是先回到大模型那里,让大模型“看到”执行结果,再生成最终回答。这个返回结果,就是回执。
回执不能只是一段普通文本,它需要包含结构化信息。一个规范的回执至少应该包含:
| 回执字段 | 作用 | 示例 |
|---|---|---|
status | 本次工具调用是否成功 | success、error、timeout |
result | 工具执行后返回的核心数据 | {"city": "北京", "weather": "晴"} |
error | 如果失败,失败原因是什么 | "API key invalid" |
execute_time | 本次执行耗时 | 120ms |
next_action | 可选的后续动作提示 | "需要用户确认后写入" |
有了这个回执,大模型就能做出正确的下一步判断:如果status是success,它会把结果整理成用户能看懂的语言;如果status是error,它会尝试修复(比如重新生成参数),或者如实告诉用户“工具调用失败了,原因是……”。
2.4 为什么说“回执”比“手”更容易被忽略
工具调用已经算是一个广为人知的概念了,各大模型厂商也都提供了函数调用 API。但“回执”却常常被忽略——尤其是很多新手在低代码平台上拖拽工具节点时,只关注“工具能不能跑通”,却忽略了“工具跑完以后,整个流程怎么根据结果分支”。
举一个真实工作流里非常常见的场景:一个智能体需要调用“创建数据库记录”的工具。如果这个工具执行成功,工作流应该继续走到“通知用户”;如果执行失败(比如字段校验不通过),工作流应该走“重新生成参数”或者“人工介入”。如果你在设计工具时没有把status返回出来,那么无论成功还是失败,工作流都会走同一条路,整个智能体的可靠性就等于零。
所以,这篇文章反复强调的一个判断是:只接“手”不接“回执”的智能体,跟只做“人工客服转人工”的自动回复没有本质区别,只是把不确定性往后甩了一手而已。
3. 环境准备与前置条件
接下来进入实操环节。我们用一个最小的 Python 项目来演示“手”和“回执”是怎么接起来的。
3.1 运行环境
- 操作系统:Windows / macOS / Linux 均可;
- Python 版本:3.9 以上(推荐 3.10 或 3.11);
- 网络环境:需要能访问大模型 API 服务。不同地区的网络情况不一样,这里建议使用符合当地合规要求的官方渠道。如果在开发环境中访问外部 API 不稳定,可以考虑使用企业内网代理,并确保所有网络操作符合公司安全规范;
- API Key:准备一个支持函数调用能力的大模型 API Key。没有的话,也可以用本地部署的大模型,但需要确认该模型是否支持 Function Calling。
3.2 Python 依赖
我们需要两个库:
openai:用于调用大模型的函数调用能力;python-dotenv:用于加载.env文件中的 API Key,避免把密钥写死在代码里。
安装命令:
pip install openai python-dotenv在项目根目录下创建.env文件:
OPENAI_API_KEY=你的APIKey OPENAI_MODEL=gpt-4o-mini如果你的模型服务商提供了自定义 Base URL,例如企业内部部署的网关,则可以使用:
OPENAI_API_KEY=你的APIKey OPENAI_BASE_URL=https://your-gateway.example.com/v1 OPENAI_MODEL=gpt-4o-mini提醒:.env文件绝不要上传到 GitHub 仓库,建议添加到.gitignore。
3.3 测试模型是否支持函数调用
在写完整代码之前,可以先做一个最小验证:向模型发送一条消息,同时传入一个简单的工具定义,看看模型是否返回tool_calls字段。如果这一步能通过,说明你的模型和 API 通道是支持函数调用的。
import os from openai import OpenAI from dotenv import load_dotenv load_dotenv() client = OpenAI( api_key=os.getenv("OPENAI_API_KEY"), base_url=os.getenv("OPENAI_BASE_URL"), ) response = client.chat.completions.create( model=os.getenv("OPENAI_MODEL"), messages=[{"role": "user", "content": "北京今天天气怎么样?"}], tools=[{ "type": "function", "function": { "name": "get_weather", "description": "获取指定城市的天气信息", "parameters": { "type": "object", "properties": { "city": {"type": "string", "description": "城市名称"} }, "required": ["city"] } } }], ) print(response.choices[0].message)如果模型支持函数调用,返回的message里会包含tool_calls,里面有function.name和function.arguments。如果返回的是普通文本(例如“我无法获取实时天气”),说明当前模型或 API 配置不支持函数调用。
4. 核心流程拆解:一个完整工具调用的生命周期
在写完整代码之前,先梳理清楚一个带工具调用的请求在内部是如何流转的。这能帮你少踩很多坑。
4.1 一次完整的调用流程
整个流程可以概括为“请求—计划—执行—回执—生成”五个阶段:
- 用户输入:用户说“北京今天天气怎么样?”;
- 模型决策(计划):大模型收到消息,判断出用户需要查询天气,于是返回一个结构化的工具调用指令,比如
调用 get_weather(city="北京"); - 代码执行(执行):你的后端代码识别到这个指令,执行真实的
get_weather("北京")函数; - 回执返回:代码把执行结果——包括状态和数据——作为一条
tool消息追加到对话上下文中,送回给大模型; - 最终生成:大模型看到回执后,生成面向用户的最终回答,比如“北京今天晴,气温 26 度,适合出门”。
这个流程里的第二步和第四步是最关键的。第一步是纯对话,第五步是纯生成,只有第二步和第四步是“智能体”区别于“聊天机器人”的核心。
4.2 伪代码视角
用伪代码表示,整个流程是这样的:
message = [{"role": "user", "content": "北京今天天气怎么样?"}] while True: response = llm.chat(messages=message, tools=TOOL_LIST) if response.has_tool_calls(): # 第二步:模型打算调用工具 tool_call = response.tool_calls[0] # 第三步:执行真实工具 result = execute_tool(tool_call.name, tool_call.arguments) # 第四步:把回执追加到上下文 message.append({ "role": "tool", "tool_call_id": tool_call.id, "content": json.dumps(result) }) # 继续循环,让模型基于回执生成最终回答 else: # 第五步:模型没有调用工具,直接生成回答 return response.content看到这个循环你就会明白,为什么“回执”是闭环的关键:role="tool"这一条消息,就是大模型看到执行结果的唯一途径。如果你不把回执放到消息里,模型根本不知道工具执行得怎么样。
4.3 为什么要用循环而不是一次调用
很多初学者会以为,用户问一句,模型调用一次工具,整个流程就结束了。但真实场景要复杂得多。比如用户说“帮我查一下北京的天气,如果下雨就提醒我带伞”。模型可能需要先调用天气工具,然后在拿到回执后,再判断是否需要继续输出提醒。如果一次请求只允许模型调一次工具,这个需求就无法实现。
所以在工程实现中,通常会使用一个while循环,允许多轮工具调用。模型的每一次工具调用和回执,都会被追加到消息序列中,直到模型不再发起工具调用为止。这样既支持了复杂任务,也让整个流程可追踪、可审计。
5. 完整示例代码实现
现在我们把上面的流程落地成代码。考虑到不同读者的技术栈,我准备了三个示例:一个是基于 OpenAI 函数调用的完整实现;一个是手写的最小工具注册与回执框架;还有一个是在 Dify 这类低代码平台里配置工具与回执的思路。
5.1 示例一:基于 OpenAI Function Calling 的完整实现
这是一个最标准的智能体最小闭环:模型调用工具、代码执行工具、回执返回模型、模型生成最终回答。
# 文件路径:agent_demo.py import os import json from openai import OpenAI from dotenv import load_dotenv load_dotenv() client = OpenAI( api_key=os.getenv("OPENAI_API_KEY"), base_url=os.getenv("OPENAI_BASE_URL"), ) # ---------- 工具定义 ---------- # 这里是一个模拟天气服务,实际项目中替换为真实 API 调用 def get_weather(city: str) -> dict: # 模拟返回,实际项目可以改为 requests.get(...) data = { "city": city, "weather": "晴", "temperature": 26, "tips": "适合户外活动", } return {"status": "success", "result": data} def execute_tool(name: str, arguments: str) -> dict: args = json.loads(arguments) if name == "get_weather": # 在真实项目中,这里要增加异常捕获 result = get_weather(city=args["city"]) return result else: return {"status": "error", "error": f"unknown tool: {name}"} # 工具的 JSON Schema 定义,供模型理解 TOOL_LIST = [ { "type": "function", "function": { "name": "get_weather", "description": "获取指定城市的天气情况,包括温度、天气状况和出行建议", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "城市名称,例如:北京、上海、广州" } }, "required": ["city"] } } } ] # ---------- Agent 主循环 ---------- def run_agent(user_input: str) -> str: messages = [{"role": "user", "content": user_input}] while True: response = client.chat.completions.create( model=os.getenv("OPENAI_MODEL"), messages=messages, tools=TOOL_LIST, ) message = response.choices[0].message # 如果模型没有发起工具调用,说明它已经生成了最终回答 if not message.tool_calls: return message.content # 模型发起了工具调用 messages.append({ "role": "assistant", "content": message.content, "tool_calls": [ { "id": tc.id, "type": "function", "function": { "name": tc.function.name, "arguments": tc.function.arguments } } for tc in message.tool_calls ], }) # 逐个执行工具,并把回执追加到消息中 for tool_call in message.tool_calls: tool_name = tool_call.function.name tool_arguments = tool_call.function.arguments print(f"[执行工具] {tool_name}({tool_arguments})") result = execute_tool(tool_name, tool_arguments) messages.append({ "role": "tool", "tool_call_id": tool_call.id, "content": json.dumps(result, ensure_ascii=False) }) if __name__ == "__main__": answer = run_agent("北京今天天气怎么样?我需要知道要不要带伞。") print(f"[最终回答] {answer}")代码逻辑说明:
get_weather函数模拟了真实工具;实际项目中,这里可以替换为任何 API 调用、数据库查询或内部服务请求。execute_tool是工具路由层,负责根据模型返回的函数名分发到具体函数。run_agent里的while循环是核心。每次模型返回后,先判断有没有tool_calls;有就执行工具,然后追加回执;没有就返回最终答案。- 回执内容使用了
json.dumps(result, ensure_ascii=False),确保中文不会被转义成\u字符,否则模型读到中文会很不友好。
运行方式:
python agent_demo.py5.2 示例二:手写一个最小工具注册与回执框架
很多团队不会直接用 OpenAI SDK,而是希望自己封装一层,方便切换模型厂商。下面是一个极简的、不依赖任何 Agent 框架的工具注册与回执处理示例,适合理解底层的设计思路。
# 文件路径:minimal_agent_framework.py import json from enum import Enum class ToolStatus(str, Enum): SUCCESS = "success" ERROR = "error" TIMEOUT = "timeout" class ToolResult: """回执数据模型,所有工具统一返回该结构""" def __init__(self, status: ToolStatus, result=None, error=None, execute_time_ms: int = 0): self.status = status self.result = result self.error = error self.execute_time_ms = execute_time_ms def to_dict(self): return { "status": self.status.value, "result": self.result, "error": self.error, "execute_time_ms": self.execute_time_ms, } class ToolRegistry: """工具注册中心:管理所有可以被模型调用的工具""" def __init__(self): self._tools = {} def register(self, name: str, description: str, parameters: dict, handler): self._tools[name] = { "name": name, "description": description, "parameters": parameters, "handler": handler, } def get_schema_list(self): """转换成模型需要的 JSON Schema 列表""" schemas = [] for tool in self._tools.values(): schemas.append({ "type": "function", "function": { "name": tool["name"], "description": tool["description"], "parameters": tool["parameters"], } }) return schemas def execute(self, name: str, arguments: str) -> ToolResult: tool = self._tools.get(name) if not tool: return ToolResult( status=ToolStatus.ERROR, error=f"tool '{name}' not found" ) try: args = json.loads(arguments) result = tool["handler"](**args) return ToolResult(status=ToolStatus.SUCCESS, result=result) except Exception as e: return ToolResult( status=ToolStatus.ERROR, error=f"execute failed: {str(e)}" ) # ---------- 使用示例 ---------- registry = ToolRegistry() # 注册一个查询订单状态的工具 def query_order(order_id: str) -> dict: # 模拟查询,实际项目替换为数据库查询 if order_id.startswith("A"): return {"order_id": order_id, "status": "已发货", "tracking_no": "SF123456"} return {"order_id": order_id, "status": "待发货"} registry.register( name="query_order", description="根据订单号查询订单的物流状态", parameters={ "type": "object", "properties": { "order_id": {"type": "string", "description": "订单号"} }, "required": ["order_id"] }, handler=query_order, ) # 模拟模型返回的工具调用指令 fake_tool_call = { "name": "query_order", "arguments": json.dumps({"order_id": "A123"}) } result = registry.execute(fake_tool_call["name"], fake_tool_call["arguments"]) print(json.dumps(result.to_dict(), ensure_ascii=False, indent=2))这个示例值得记住的设计点有三个:
- 所有工具统一返回
ToolResult。这样回执结构稳定,大模型在处理不同工具的结果时,不需要判断“返回的到底是一个字符串还是字典”。 - 异常被拦截成回执里的
error字段。很多初学者会在工具函数里直接抛异常,导致整个 Agent 进程崩溃。正确的做法是在工具执行层统一捕获异常,转成回执里的status=error,让模型有机会根据错误信息做出下一步决策。 - 注册中心扮演了“说明书管理器”。模型需要知道有哪些工具可用,SDK 需要根据注册信息生成 schema,执行时需要根据名称找到对应函数。这三个需求,注册中心全部解决。
5.3 示例三:在 Dify / Coze 低代码平台里配置工具与回执
如果不喜欢写代码,也可以使用 Dify、Coze 这类可视化平台。不过,理解它背后的工具与回执机制,能帮你做出更复杂的 Agent。
在 Dify 中,通常的做法是:
- 创建一个“Agent”应用;
- 在“工具”面板中添加工具,可以是内置工具,也可以是自定义 OpenAPI 工具;
- 自定义工具时,需要填写工具名称、描述、接口地址和参数格式;
- 在 Agent 指令中明确告诉模型:当用户询问某类问题时,调用哪个工具;
- 工具执行完成后的返回值,会以“工具回执”的形式传给模型,模型再组织语言回复用户。
关键点在于:默认情况下,工具节点的输出就是回执内容。如果你希望回执能被流程正确分支处理,就需要让工具节点返回结构化的 JSON 字段,然后在后续节点中根据status字段做条件判断。
以自定义查询订单工具为例,在你自己的后端服务中,返回结构应该类似:
{ "status": "success", "result": { "order_id": "A123", "status": "已发货", "tracking_no": "SF123456" } }如果查询失败,则返回:
{ "status": "error", "error": "order not found" }这样在 Dify 的工作流中,你可以在工具节点之后加一个“条件分支”节点,判断status == "success"时走通知用户分支,否则走重新查询或人工介入分支。这正是“回执”这一概念在低代码平台中的具体表现。
6. 运行结果与效果验证
以示例一的代码为例,运行后你会看到类似下面的输出:
[执行工具] get_weather({"city":"北京"}) [最终回答] 北京今天天气晴朗,气温 26 度。既然没有降雨,您不需要带伞,可以放心出门。注意,这只是模拟结果。实际运行中,由于大模型的生成结果不固定,最终回答的措辞会有差异,这是正常现象。你需要验证的核心点不是“措辞”,而是流程:
[执行工具]这行日志是否出现。如果没有出现,说明模型压根没有调用工具,可能是工具描述不清晰,或者模型版本不支持函数调用;- 最终回复里是否包含天气信息。如果回答里出现了“我无法获取实时天气”之类的文本,说明模型没有正确使用工具回执;
- 连续两次运行,模型是否都能稳定触发工具调用。如果时灵时不灵,通常说明工具描述里缺少足够上下文,需要在 description 里写清楚“什么时候该调用这个工具”。
6.1 如何验证回执是否生效
最直接的方法,是在messages中打印出回执内容。你可以在run_agent循环里加一行打印:
print(f"[回执内容] {json.dumps(result, ensure_ascii=False)}")运行后,你能看到回执消息是否被正确追加到了上下文中。如果回执内容为空、乱码,或者字段缺失,先检查execute_tool的返回结构是否稳定。
6.2 日志与可观测性建议
生产环境中,建议把你自己的 Agent 封装时同步输出日志,至少记录以下内容:
# 每条日志建议包含 timestamp, request_id, user_input, model, tool_name, tool_arguments, tool_status, tool_result, response这样当用户反馈“智能体答非所问”时,你能通过日志快速定位是模型决策错、工具执行错、还是回执解析错。
7. 常见问题与排查思路
在实际开发中,比“模型输出得准不准”更让你头疼的,往往是接口调用、参数格式、上下文丢失这些工程细节。这里整理了一份高频问题排查表。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 模型不触发工具调用,总是直接回复文本 | 工具描述不够明确;模型不支持函数调用;API 没有传tools参数 | 检查请求日志中是否包含tools;换一个支持函数调用的模型再试 | 在工具 description 里写清“什么时候调用”;确认模型版本支持 Function Calling |
| 触发了工具调用,但执行时报参数错误 | 模型生成的arguments是非法 JSON,或者缺少必填字段 | 打印原始arguments字符串 | 在json.loads外层加异常捕获;在参数 schema 中使用更严格的required和enum |
| 模型执行完工具后,还是说“我无法获取信息” | 回执没有被正确追加到消息中;回执内容格式不对 | 检查消息序列中是否包含role: "tool"消息;检查tool_call_id是否匹配 | 确保每条tool消息都包含正确的tool_call_id |
| 工具执行结果中包含中文,模型读出来是乱码 | json.dumps时没有设置ensure_ascii=False | 观察回执内容字符串 | 设置ensure_ascii=False |
| Agent 陷入死循环,一直调用同一个工具 | 回执内容无法帮助模型做下一步判断;工具描述里没有“终止条件” | 查看日志中对话轮数 | 在工具描述里写清“调用完成后直接总结结果,不要重复调用”;为循环次数设置上限 |
| 工具调用超时,导致整个请求超时 | 工具自身执行时间过长 | 对工具执行做耗时统计 | 在工具外层设置超时控制,比如用functools.wraps或 asyncio 超时 |
API 返回 400 错误,提示invalid tool_calls | assistant消息里的tool_calls结构不完整 | 对比 OpenAI 官方文档中的消息结构 | 按官方要求补全id、type、function字段 |
| 回执内容过长,导致历史消息超出上下文窗口 | 工具返回了很大的数据,比如完整数据库记录 | 检查 token 占用 | 在回执中加入摘要逻辑,只返回模型需要的核心字段 |
7.1 最常见的坑:tool_call_id不匹配
在多轮工具调用的场景里,tool_call_id必须一一对应。也就是说,如果模型生成了三条tool_calls,你必须执行完这三条之后,追加三条role: "tool"消息,并且每条消息的tool_call_id分别指向对应的工具调用 ID。如果顺序乱了、ID 对不上,API 会直接报错。
7.2 另一个容易忽略的问题:工具的“副作用”
工具调用不是只读的。如果你的工具会写数据库、发邮件、调用外部支付接口,就要特别注意“模型重复调用工具”的副作用问题。模型是概率性的,它可能会对同一个操作重复调用两次。因此,凡是具有副作用的工具,要么在接口层面做幂等处理,要么在执行前增加人工确认环节。这一点在后面的最佳实践里还会再展开。
8. 最佳实践与工程建议
把“手”和“回执”接起来只是第一步。要让 Agent 从“能跑”变成“可维护、可上线”,还需要在一开始就建立一些工程约束。下面是几点经过验证的建议。
8.1 工具描述要像写“接口文档”一样严谨
大模型不是通过函数名理解工具的,它靠的是 description 里的语义。写 description 的时候,要明确回答这几个问题:
- 这个工具是干什么的?
- 什么时候应该调用这个工具?
- 什么时候不应该调用这个工具?
- 每个参数的含义和格式是什么?
反例:
{ "name": "get_weather", "description": "天气查询", "parameters": { "type": "object", "properties": { "city": {"type": "string"} } } }正例:
{ "name": "get_weather", "description": "查询指定城市当前天气状况。当用户询问天气、气温、降雨、出行建议时使用该工具。如果用户询问的是历史天气,请使用另一个工具 query_history_weather。", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "城市标准名称,例如:北京、上海、广州。不要使用缩写或拼音。" } }, "required": ["city"] } }同样的函数,后者被调用的准确率会明显高于前者。
8.2 回执要做“摘要”,不要把所有数据都丢给模型
很多工具会返回大段数据,比如查询订单可能返回几十个字段。如果你把全部字段都塞进回执,上下文会被大量无关 token 占满,不仅增加成本,还可能影响模型对重点信息的注意力。
更好的做法是,在工具执行层对返回数据做一次“模型视角的加工”:只保留对用户有意义的字段,或者直接用一段自然语言总结。例如:
{ "status": "success", "result": { "order_id": "A123", "summary": "订单已发货,物流单号 SF123456,预计 3 天后送达" } }这里的关键原则是:回执是给模型做决策用的,不是给用户看的。给模型的信息越精简,模型的最终判断越稳定。
8.3 工具层必须做超时、权限和审计
工具是 Agent 的“手”,也是风险的主要来源。一个权限失控的工具,等于把内部系统暴露给了大模型。上线前至少要做三件事:
- 超时控制:每个工具调用都要设置超时时间,比如 5 秒或 10 秒。避免某个外部接口一直不返回,导致 Agent 卡死。
- 最小权限:Agent 调工具使用的账号,只能具备完成当前任务所需的最小权限。比如查询工具只给只读权限,写操作给单独的高权限账号,并且要求审批。
- 操作审计:所有工具调用都要记录日志,包括谁发起的、调了哪个工具、传了什么参数、返回了什么结果。一旦线上出问题,要有能力回溯到具体某次调用。
8.4 为循环调用设置上限
Agent 的while循环是必要的,但“无限循环”是危险的。模型有可能陷入一个无法终止的循环里。工程上必须加上一个最大循环次数限制,例如 5 轮或 10 轮,超过后强制退出并告知用户“需要人工介入”。
MAX_TURNS = 5 def run_agent_with_limit(user_input: str) -> str: messages = [{"role": "user", "content": user_input}] for _ in range(MAX_TURNS): ... if not message.tool_calls: return message.content ... return "抱歉,任务步骤过多,建议简化需求或联系管理员处理。"8.5 让执行结果可回滚
如果工具涉及写操作,比如更新数据库、发送邮件、创建订单,必须在设计阶段就考虑“如果这一步错了怎么办”。最稳妥的方式是使用状态机:把操作分成“待确认—已执行—已完成”几个阶段。Agent 只负责把操作推进到“待确认”,由用户确认后再真正执行。这样既保留了智能体的自动化,又把风险控制在了可控范围内。
8.6 在真实项目里建议按三层设计
当你把上面的代码扩展成完整项目时,建议按照三层来组织代码,而不是把所有逻辑塞进一个函数:
- 基础设施层:负责大模型 API 调用、日志、鉴权、上下文管理;
- 工具层:负责具体业务操作,比如查数据库、调接口、发通知;每个工具都是独立函数,统一返回
ToolResult; - 编排层:负责决定“下一步调用哪个工具”,也就是 Agent 的主循环和状态管理。
三层分离后,后续新增工具、替换模型厂商、调整流程策略,都不会互相影响。
9. 总结与后续学习方向
现在回到文章标题那个判断:智能体会说还不够,得把手和回执都接上。
“手”解决的是智能体能不能行动的问题,“回执”解决的是智能体知不知道行动结果的问题。两者缺一不可。只接“手”不接“回执”,智能体就像一个蒙着眼干活的人,做完了也不知道自己做得对不对;只接“回执”不接“手”,那智能体就退化成只能动嘴皮子的聊天机器人。
这篇文章里,我们通过三个示例跑通了一个最小闭环:模型发起工具调用、代码执行工具、回执返回模型、模型生成最终回答。这套流程是几乎所有 Agent 项目的地基,不管是基于 OpenAI API、国产大模型 SDK,还是在 Dify、Coze 里拖拽配置,背后的思想都是同一个。
接下来你可以往三个方向继续深入:
- 多工具并发与规划:当一个请求需要调用多个工具时,如何决定调用顺序、如何并行执行、如何处理工具间的依赖。
- 状态记忆与多轮对话:如何在多轮对话中维护用户状态,让智能体记住上一个任务的执行结果。
- 生产级稳定性:如何做重试、熔断、限流、人工审批流,以及如何评估一个 Agent 的工具调用准确率。
建议你把示例代码下载到本地,先跑通最小闭环,再加上一个你业务里真实存在的工具(比如查订单、查库存)。当你第一次看到模型根据工具回执,自动给出正确结论的时候,那种“Chatbot 变成 Agent”的实感就会非常清晰了。
如果这篇文章对你有帮助,建议收藏备用。你在实际项目里接“手”和“回执”时踩过哪些坑,欢迎在评论区交流。