本文来自《AI 应用开发课程》月份 1 课程文档,已整理为网站文章版本。
学习目标
学完本节后,你应当能够:
- 设计月份 1 的最小工具注册与执行结构。
- 实现一次完整的 Tool Calling 程序闭环。
- 为后续 Agent 学习建立最基础的执行循环思维。
前置知识
- 已理解 Tool Calling 原理
1. 月份 1 的统一抽象
推荐先围绕下面两个对象实现:
ToolRegistryToolExecutor
再在 LLM client 中增加:
call_with_tools()
2. 最小数据模型
from typing import Any
from pydantic import BaseModel
class ToolDefinition(BaseModel):
name: str
description: str
parameters: dict[str, Any]
class ToolCallResult(BaseModel):
tool_name: str
success: bool
output: str
3. ToolRegistry 的职责
- 注册工具定义
- 根据工具名查找执行函数
- 列出当前可用工具
4. ToolExecutor 的职责
- 接收模型返回的工具名和参数
- 查找对应工具
- 执行工具
- 捕获异常
- 返回
ToolCallResult
5. 最小循环伪代码
async def call_with_tools(messages: list[ChatMessage]) -> str:
first_response = await llm_client.ask_with_tool_schema(messages, tools)
if not first_response.tool_calls:
return first_response.answer
tool_results = []
for tool_call in first_response.tool_calls:
result = executor.execute(tool_call.name, tool_call.arguments)
tool_results.append(result)
followup_messages = build_followup_messages(messages, first_response, tool_results)
final_response = await llm_client.generate(followup_messages)
return final_response
6. 关键设计点
工具执行不要写在入口层
否则以后 CLI、FastAPI、LangChain 都很难复用。
工具参数必须先校验
不要拿模型给的参数直接执行。
工具结果必须结构化
否则回填给模型和日志记录都会变得混乱。
7. 推荐的最小目录
app/
├── tools/
│ ├── definitions.py
│ ├── registry.py
│ └── executor.py
└── services/
└── tool_service.py
8. 实操任务
- 实现
ToolRegistry - 实现
ToolExecutor - 注册一个
extract_todo_items工具 - 完成“模型返回工具请求 -> 程序执行 -> 回填结果”的闭环
9. 自测题
- 为什么工具执行器不应该直接返回裸字符串?
- 为什么工具调用闭环至少需要两次模型参与?
- 为什么
ToolRegistry和ToolExecutor要分开?
10. 作业与验收
作业:
- 实现
LLMClient.call_with_tools()
验收标准:
- 无工具时能直接回答
- 有工具时能完成闭环
- 工具结果能回填
11. 常见错误
- 工具注册表和执行函数映射混乱
- 模型返回工具请求后没有二次调用模型
- 工具结果没有保留 success 状态
12. 本章与前文关系
上一章已经把 Tool Calling 的职责边界讲清楚了,这一章开始真正落代码。你会在这里第一次把月份 1 前面学过的很多东西真正连起来:
- Pydantic 模型
- 项目分层
- 异步调用
- 错误处理
- 结构化结果
这也是月份 1 从“会调模型”升级到“会做最小助手闭环”的关键一步。
13. 本章在研发助手项目中的位置
研发助手项目中的很多功能,其实都可以抽象成:
- 用户给出任务
- 模型判断是否需要工具
- 程序执行工具
- 模型基于工具结果生成最终答复
所以本章写出来的工具系统,后面几乎可以原样迁移到综合项目。
14. 完整文件级示例:definitions.py + registry.py + executor.py + tool_service.py
definitions.py
"""定义月份 1 的工具数据结构。"""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel
class ToolDefinition(BaseModel):
name: str
description: str
parameters: dict[str, Any]
class ToolCall(BaseModel):
tool_name: str
arguments: dict[str, Any]
class ToolCallResult(BaseModel):
tool_name: str
success: bool
output: str
registry.py
"""工具注册表。"""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from app.tools.definitions import ToolDefinition
class ToolRegistry:
"""维护工具定义和执行函数映射。"""
def __init__(self) -> None:
self._definitions: dict[str, ToolDefinition] = {}
self._handlers: dict[str, Callable[..., str]] = {}
def register(
self,
definition: ToolDefinition,
handler: Callable[..., str],
) -> None:
self._definitions[definition.name] = definition
self._handlers[definition.name] = handler
def get_definition_list(self) -> list[dict[str, Any]]:
return [definition.model_dump() for definition in self._definitions.values()]
def get_handler(self, tool_name: str) -> Callable[..., str]:
if tool_name not in self._handlers:
raise KeyError(f"未注册工具: {tool_name}")
return self._handlers[tool_name]
executor.py
"""工具执行器。"""
from __future__ import annotations
from app.tools.definitions import ToolCallResult
from app.tools.registry import ToolRegistry
class ToolExecutor:
"""负责实际执行工具。"""
def __init__(self, registry: ToolRegistry) -> None:
self.registry = registry
def execute(self, tool_name: str, arguments: dict[str, object]) -> ToolCallResult:
"""执行单个工具并返回结构化结果。"""
try:
handler = self.registry.get_handler(tool_name)
except KeyError:
return ToolCallResult(
tool_name=tool_name,
success=False,
output=f"工具不存在: {tool_name}",
)
try:
result = handler(**arguments)
return ToolCallResult(
tool_name=tool_name,
success=True,
output=result,
)
except Exception as error:
return ToolCallResult(
tool_name=tool_name,
success=False,
output=f"工具执行失败: {error}",
)
tool_service.py
"""组织工具调用闭环的 service。"""
from __future__ import annotations
from app.tools.definitions import ToolCall, ToolCallResult
from app.tools.executor import ToolExecutor
class ToolService:
"""围绕执行器提供更高一层的编排逻辑。"""
def __init__(self, executor: ToolExecutor) -> None:
self.executor = executor
def run_tool_call(self, tool_call: ToolCall) -> ToolCallResult:
"""执行模型返回的单次工具请求。"""
return self.executor.execute(
tool_name=tool_call.tool_name,
arguments=tool_call.arguments,
)
15. 逐段解释这组完整示例
为什么 ToolDefinition、ToolCall、ToolCallResult 要分开
因为它们表示的是三个完全不同的阶段:
- 定义阶段:有哪些工具、工具做什么
- 请求阶段:模型这次想调用哪个工具、传什么参数
- 结果阶段:工具执行后成功还是失败、返回了什么
把这三者混成一个对象,后面一定会乱。
为什么 ToolRegistry 和 ToolExecutor 要分开
注册表解决“有哪些工具”,执行器解决“怎么执行工具”。这是典型的职责分离。
为什么 ToolCallResult 一定要保留 success
因为光有 output 不够。后面模型回填和日志记录都需要知道:
- 这是成功结果
- 还是失败结果
16. 无工具、单工具成功、参数错误、工具失败,分别意味着什么
这是月份 1 Tool Calling 必须清楚区分的 4 条路径:
路径一:无工具
模型认为不需要调用任何工具,直接回答。
路径二:单工具成功
模型请求某个工具,程序执行成功,并把结果回填给模型。
路径三:参数错误
模型请求的工具名存在,但参数结构不合法,程序应尽早拦下。
路径四:工具执行失败
工具本身运行时出错,程序应把失败结果结构化返回,而不是静默吞掉。
17. 一个更接近研发助手的工具函数示例
你可以为注册表准备这样的 handler:
def extract_todo_items(task_text: str) -> str:
return f"从任务中抽取待办项: {task_text}"
def draft_commit_message(diff_summary: str) -> str:
return f"feat: summarize changes from diff - {diff_summary}"
月份 1 允许这些工具的实现先保持简单,关键在于闭环结构和职责边界。
18. 为什么工具循环至少会有两轮模型参与
这是 Tool Calling 经常被忽略的一点:
- 第一轮:模型决定要不要用工具
- 程序执行工具
- 第二轮:模型基于工具结果产出最终回答
如果你省掉第二轮模型参与,很多场景下就无法把工具结果自然整合成最终输出。
19. 调试与排错:本章最常见问题
问题一:工具定义名和注册名不一致
会导致模型“看得到这个工具”,但程序执行不到。
问题二:handler 参数和 arguments 键不匹配
这会直接在执行阶段报错。
问题三:只返回字符串,不返回结构化结果
这样后面无法稳定记录成功/失败和工具名。
20. 本章完成后你应该具备的能力
完成本章后,你应当做到:
- 能自己写出最小工具注册表和执行器。
- 能解释 4 条常见工具路径。
- 能为后续模型回填准备结构化结果。
- 能把工具系统迁移到综合项目。
21. 如果你卡在这里,先回看哪几章
- 模型结构化结果不清楚:回看 03-结构化输出与JSON Schema.md
- Pydantic 模型边界不稳:回看 03-pydantic与配置建模.md
22. 从本章过渡到下一章的桥接说明
接下来进入 03-错误处理与重试.md。
因为工具系统一旦落地,失败路径就不再是偶发问题,而是系统设计的一部分。下一章会把:
- 什么错误可以重试
- 什么错误必须立即暴露
- 如何记录工具上下文
讲清楚。