WRITING / 2026.09.23

让 Agent 真正执行任务:Tool Calling 与 MCP 实战

围绕研发排障助手,把状态查询和排障记录封装成可校验的工具,梳理模型请求、应用执行与 MCP 连接的职责。通过锁定版本的官方 Python SDK 实测工具发现和调用,并用离线案例解释参数、超时、权限与幂等边界。

模型说“我需要查询结算服务状态”,并不意味着查询已经发生。它只是提出了一个请求。应用需要判断请求是否合法,找到对应业务能力,执行查询,再把结果交回模型。工具调用最容易被忽略的地方,正是这段由应用负责的执行过程。

本文继续使用虚构的 checkout 排障场景。模型动作来自预置脚本,状态与变更记录来自固定数据;参数校验、SQLite 写入和 MCP SDK 调用则实际运行。我们关注的是工具契约与连接方式,不用一次模拟成功来证明模型已经具备可靠的工具选择能力。

工具接口先回答一个业务问题

如果已有一个状态查询接口,最直接的封装方式可能是把整个接口文档交给模型。但业务接口面向程序调用方,工具描述还需要让模型判断“什么时候该调用”。只说明路径和参数类型,往往不足以表达用途和边界。

本例提供 get_service_status,用途是读取虚构服务的固定状态快照。它接受服务名,返回服务版本、延迟、连接等待数、CPU 使用率和观测时间。描述里明确说明它不执行重启、不调整配置,也不访问真实生产环境,避免名称让读者产生超出能力范围的理解。

工具名称应尽量对应一个清楚的动作。如果一个工具叫“万能运维”,参数里又包含查询、修改和执行命令等模式,模型需要在同一个入口理解多种权限与风险,应用也难以为不同操作设置独立边界。按业务能力拆分后,工具选择与执行授权都更容易检查。

返回值应保留机器可读的字段。字符串“状态不太好”无法告诉后续逻辑异常发生在什么时候,也无法可靠比较不同查询。结构化结果可以携带来源标识与时间,同时附加适合人阅读的解释。解释负责帮助理解,字段负责让程序继续工作。

一次 Tool Calling 如何完成

完整流程包含工具定义、模型调用请求、应用校验、实际执行、结果回传和后续决策。模型输出的工具名和参数只是其中一个中间产物。应用收到它们后,必须先找到允许调用的实现,而不是把名字当成可执行代码。

工具调用时序:模型提出请求,应用校验身份与参数,工具执行后返回结构化观察,再进入下一轮决策

在示例中,Tools.call 使用固定字典描述允许的工具与参数。状态查询只接受一个字符串服务名。额外传入 actor 字段会被拒绝,因为当前身份来自可信执行上下文,不能由模型通过工具参数自行声明。这个约束比一句“请不要越权”更容易验证。

from demo import database, Tools

with database() as db:
    tools = Tools(db, actor="alice")
    result = tools.call("get_service_status", {"service": "checkout"})
    print(result["pending_connections"])

这段代码返回六十四,数值来自教学快照。执行器可以将结果记录为观察,但不应该据此宣布根因已经确定。工具通常提供事实或完成一个明确动作;将多个观察组合成判断,是后续决策与评测需要处理的问题。

参数正确包含不止一种含义

第一层是结构正确:参数是不是对象,字段名是否允许,服务名是不是字符串。第二层是业务有效:该服务是否存在,目标版本是否支持查询。第三层是权限允许:当前用户能不能看到这个服务。只完成第一层,不能认为工具调用已经安全。

字符串也需要长度和取值边界。过长的查询可能消耗不必要的上下文和检索资源,空字符串则可能触发全量读取。示例对每个参数设置长度上限,同时拒绝空值与多余字段。实际系统可以复用已有的 DTO 校验与领域规则,但不要只依赖模型遵守提示。

输出同样属于契约。状态字段不完整时,应用不能悄悄补一个看起来合理的默认值。例如缺少观测时间,后续决策就无法判断数据是否过期。应明确表达缺失、错误或部分成功,让调用方决定是否重新查询。工具能否正确失败,是接口设计的一部分。

错误信息也要避免混入可执行建议。第三方返回的文字可能包含“执行某命令即可修复”,这属于外部数据,不能自动升级为应用指令。工具适配层可以保留原始错误码和必要摘要,具体是否采取下一步动作仍由受约束的执行器判断。

Tool Calling 与 MCP 分别承担什么

Tool Calling 描述模型如何提出结构化工具调用,以及应用如何处理这种调用。不同模型接口可能使用不同消息结构。MCP 则提供应用与外部能力之间的协议边界,用于发现和调用工具,以及访问其他上下文能力。二者处在不同层面,可以共同使用。

Host 是承载 AI 功能的应用,负责用户交互、模型连接和能力管理。Client 是 Host 与某个 Server 通信的协议客户端。Server 暴露具体能力,例如读取排障文档或查询状态。连接了 Server,并不意味着其中每个工具都应对所有用户或任务开放,Host 仍需要决定可用范围。

MCP 中的 tools、resources 和 prompts 也不应混为一谈。工具表达可调用能力,资源表达可读取的信息,提示模板表达可复用的交互内容。把一份文档暴露为资源,并不代表它可以向 Host 下达系统级指令;从外部取得的内容仍然具有自己的信任边界。

对已有 Java 服务而言,MCP Server 可以是一层适配器,复用既有查询接口。它不要求重写业务数据库,也不替代原服务的鉴权。更稳妥的方式是让每一层都有清楚的职责:协议层处理连接与结构,业务层处理身份、数据权限和领域规则。

使用官方 Python SDK 验证协议调用

本系列实际验证的版本是 mcp==2.2.0,完整依赖锁定在下载包的 requirements.txt。当前代码使用第二版 SDK 的 MCPServerClient;阅读旧版示例时,不能直接假定导入路径、返回对象和字段名称完全相同。

下面是下载包中工具注册部分的关键代码。状态读取复用第一篇的业务函数,工具注册层不重新实现一套查询逻辑。

from mcp import Client
from mcp.server import MCPServer
from demo import service_status

server = MCPServer("Offline incident tools")

@server.tool(structured_output=True)
def get_service_status(service: str) -> dict[str, object]:
    """查询虚构服务的固定状态快照;service 必须为 checkout。"""
    return service_status(service)

客户端通过官方 SDK 的内存连接完成工具发现与调用。这里确实运行了 SDK 的客户端与服务端处理流程,但没有启动 HTTP 服务或子进程传输,也没有接入模型。这样的集成测试适合检查工具契约,网络断开、远程鉴权等问题则需要另外的部署环境验证。

async with Client(server) as client:
    listing = await client.list_tools()
    names = [tool.name for tool in listing.tools]
    result = await client.call_tool(
        "get_service_status", {"service": "checkout"}
    )
    snapshot = result.structured_content

在这个固定版本中,list_tools() 返回包含 tools 字段的结果对象;结构化输出通过 structured_content 读取。示例明确开启结构化输出,测试会检查发现的工具名、服务名和等待连接数。因此,验证不止是“调用没有抛异常”,还检查返回数据能否被业务程序实际使用。

运行 python mcp_example.py 可以看到发现的工具和状态快照。安装依赖后,这个程序不需要网络与模型密钥。它证明 SDK 工具发现和调用能够连接起来;至于模型是否会在合适时机选择这个工具,仍然需要真实模型评测。

超时、取消和重试怎样设计

工具超时不应返回一条伪造的正常观察。本例提供超时故障注入,能够让指定工具返回 TOOL_TIMEOUT,用来测试上层是否停止以及是否记录错误。它模拟的是故障结果,并没有通过真实慢网络测量超时触发时间,这两类测试的证据不同。

真实工具应根据调用方的剩余时间设置截止时间。如果任务还剩两秒,而工具默认等待三十秒,单个工具就能突破任务预算。超时策略需要贯穿模型调用、工具连接和下游请求,并区分可以取消的等待与已经提交的副作用。

取消也不等于撤销。客户端停止等待一个写请求时,服务端可能已经写入数据库。重新发起相同请求之前,应该先根据业务标识确认结果。只因为没有收到响应就认为操作没发生,会在订单、工单和配置变更场景造成重复动作。

重试策略应考虑错误类型。网络抖动可能适合有限重试,参数不合法通常需要修正输入,权限拒绝不应该通过重复调用解决。退避、次数限制和任务预算应一起生效,避免单个工具在失败时占满全部执行时间。

用排障记录说明幂等

案例中的写工具只向临时 SQLite 添加一条排障记录。当前身份与请求编号组成唯一键,第一次成功后,重复提交相同内容返回同一记录。相同编号搭配不同内容则返回 IDEMPOTENCY_CONFLICT,避免请求标识被错误复用。

from demo import database, Tools

with database() as db:
    writer = Tools(db, actor="alice", writable=True)
    args = {"request_id": "incident-42-note-1", "text": "请核对连接池配置"}
    first = writer.call("add_incident_note", args)
    retry = writer.call("add_incident_note", args)
    assert first == retry

SQLite 唯一约束承担最终去重,应用随后核对保存的内容是否一致。仅在内存里维护一个“执行过的请求集合”不足以处理进程重启和多个实例同时接收请求。教学代码的事务范围很小,便于观察;真实系统还需要考虑保留期限、冲突响应和业务操作原子性。

请求编号应代表一次业务意图,而不是每次重试重新生成。否则相同操作会得到不同编号,唯一约束自然不会阻止重复。另一方面,把所有操作都塞进同一个编号也不正确,因为不同内容会变成持续冲突。编号设计必须与业务动作的粒度一致。

writable=True 是由测试代码建立的可信上下文,用来说明只读与可写工具权限分离。它不是面向真实用户的授权方案。生产系统应从已验证的身份与权限策略产生执行上下文,并在服务端检查,不能让模型通过传一个布尔值自行获得写权限。

工具返回多少内容才合适

工具输出过少,模型无法判断下一步;输出过多,又会占据上下文并掩盖关键字段。状态查询可以先返回服务版本、观测时间和异常指标,详细日志通过另一个受控入口按需读取。这样既保留追查路径,也避免每次查询都搬运完整监控数据。

分页与截断需要让调用方看得出来。如果返回值只包含前十条变更,却没有说明还有更多,模型可能把它当成全部历史。工具输出可以保留游标、总数或明确的截断标识,应用再根据预算决定是否继续读取。省略信息本身也是需要描述的接口行为。

还应区分可公开展示的信息和仅供内部计算的信息。工具返回可能包含内部地址或用户数据,最终答案不必照单全收。将结果放进上下文之前进行必要筛选,并在日志中控制敏感字段,比在最终文本生成后再尝试删除秘密更容易建立一致边界。

Java 开发者可以如何落地

可以将工具定义视为一层清晰的应用服务契约。参数校验类似请求 DTO 校验,工具分派类似受限的命令处理器,业务实现继续进入 Service,数据库唯一键和事务负责幂等。MCP 适配层负责协议,并不需要接管这些领域职责。

工具设计完成后,至少验证三类事情:什么输入会被拒绝,什么失败能够被调用方识别,重复执行会不会改变业务结果。示例的工具测试覆盖多余身份字段、错误类型、注入超时、只读写入拒绝以及请求内容冲突。官方 SDK 集成测试则额外验证工具发现和结构化输出。

对于远程部署,还应测试连接建立、认证失效、会话中断、并发调用和版本兼容。本系列没有把这些测试伪装成已经完成的结果,而是把它们列为从本地协议验证走向真实服务所需的下一层证据。一个工具能够本地调用,距离可以交给长期运行的 Agent 使用,仍有明确的工程步骤。

用一次接口评审发现隐藏问题

假设最初的工具叫查询服务,返回一整份内部监控响应。评审时可以先问:调用者是否能明确指定环境,返回值是否包含采样时间,字段缺失时有没有错误标识?如果这些问题答不上来,模型即使正确调用了接口,也无法可靠使用结果。改进工具往往先要改清契约,而不是增加提示词。

再看另一个工具:它接受一段自由文本,内部根据关键词决定查询还是修改配置。这个设计把模式选择隐藏在工具实现里,模型与执行器都难以提前判断是否会写入。更清楚的接口应把查询与变更分开,变更操作接收明确目标和参数,并由受信任的授权流程控制。

错误返回也可以用反例评审。服务不存在时,如果返回空对象,调用方可能把它当作没有异常;权限不足时,如果返回完整底层异常,可能泄露内部实现。稳定的错误码配合经过筛选的说明,能够让执行器决定修正输入、停止任务或转交人工,同时减少不必要的信息暴露。

工具上线后还会演进。新增可选输出字段通常容易兼容,改变已有字段的含义则可能使旧提示和旧评测静默失效。应将工具契约版本与测试案例一起维护,必要时同时保留旧接口,观察调用方迁移情况。不要在同名字段中悄悄改变单位,例如把毫秒改成秒却继续使用相同名称。

最后检查工具的可观测性:一次调用能否关联任务编号,是否知道它执行了多久,重试是否复用了相同业务标识?这些信息既帮助开发者调试,也帮助用户理解当前状态。模型能够解释一个错误,并不意味着系统已经具备排查这个错误的证据。

另一个容易遗漏的细节是空值与零值。连接等待数为零是有效观察,字段缺失则表示没有取得这项数据。适配层若将两者都转换为零,后续回答会错误地声称系统不存在等待。评审时使用缺失字段、合法零值和异常类型三组数据,比只测试一份完整响应更容易发现这类问题。

参考资料

AI Agent 工程实践:从原理到可靠运行

  1. AI Agent 是如何工作的:从一次模型调用到任务执行闭环
  2. 让 Agent 真正执行任务:Tool Calling 与 MCP 实战 · 当前文章
  3. 从知识库问答到 Agentic RAG:让 Agent 找到可靠答案
  4. Agent 的上下文与记忆:如何记住有用信息
  5. 复杂任务如何编排:工作流、单 Agent 与多 Agent 的取舍
  6. Agent 从 Demo 到上线:评测、可观测性与安全边界

按时间浏览