← Back to 文字

工具循环实现

本文来自《AI 应用开发课程》月份 1 课程文档,已整理为网站文章版本。

学习目标

学完本节后,你应当能够:

  • 设计月份 1 的最小工具注册与执行结构。
  • 实现一次完整的 Tool Calling 程序闭环。
  • 为后续 Agent 学习建立最基础的执行循环思维。

前置知识

  • 已理解 Tool Calling 原理

1. 月份 1 的统一抽象

推荐先围绕下面两个对象实现:

  • ToolRegistry
  • ToolExecutor

再在 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. 实操任务

  1. 实现 ToolRegistry
  2. 实现 ToolExecutor
  3. 注册一个 extract_todo_items 工具
  4. 完成“模型返回工具请求 -> 程序执行 -> 回填结果”的闭环

9. 自测题

  1. 为什么工具执行器不应该直接返回裸字符串?
  2. 为什么工具调用闭环至少需要两次模型参与?
  3. 为什么 ToolRegistryToolExecutor 要分开?

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. 逐段解释这组完整示例

为什么 ToolDefinitionToolCallToolCallResult 要分开

因为它们表示的是三个完全不同的阶段:

  • 定义阶段:有哪些工具、工具做什么
  • 请求阶段:模型这次想调用哪个工具、传什么参数
  • 结果阶段:工具执行后成功还是失败、返回了什么

把这三者混成一个对象,后面一定会乱。

为什么 ToolRegistryToolExecutor 要分开

注册表解决“有哪些工具”,执行器解决“怎么执行工具”。这是典型的职责分离。

为什么 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 经常被忽略的一点:

  1. 第一轮:模型决定要不要用工具
  2. 程序执行工具
  3. 第二轮:模型基于工具结果产出最终回答

如果你省掉第二轮模型参与,很多场景下就无法把工具结果自然整合成最终输出。

19. 调试与排错:本章最常见问题

问题一:工具定义名和注册名不一致

会导致模型“看得到这个工具”,但程序执行不到。

问题二:handler 参数和 arguments 键不匹配

这会直接在执行阶段报错。

问题三:只返回字符串,不返回结构化结果

这样后面无法稳定记录成功/失败和工具名。

20. 本章完成后你应该具备的能力

完成本章后,你应当做到:

  1. 能自己写出最小工具注册表和执行器。
  2. 能解释 4 条常见工具路径。
  3. 能为后续模型回填准备结构化结果。
  4. 能把工具系统迁移到综合项目。

21. 如果你卡在这里,先回看哪几章

22. 从本章过渡到下一章的桥接说明

接下来进入 03-错误处理与重试.md

因为工具系统一旦落地,失败路径就不再是偶发问题,而是系统设计的一部分。下一章会把:

  • 什么错误可以重试
  • 什么错误必须立即暴露
  • 如何记录工具上下文

讲清楚。

Fin