Agentic AI (三)工具使用:从函数调用到 MCP

文章内容

本文内容整理自吴恩达(Andrew Ng)《Agentic AI》课程模块3「工具使用」

LLM 只会生成文本 token,它凭什么能查数据库、发日历邀请、跑一段 Python?答案是它压根不执行任何东西。工具使用(tool use)的真实链路是:LLM 按约定格式输出一个调用请求,宿主代码解析这个请求、真正执行函数、把返回值写回对话历史,LLM 再基于这段历史继续生成。「请求—执行—回填」这个三段式一直没变,变的是请求怎么表达——从开发者手写文本协议,到框架读 docstring 自动生成 JSON Schema,再到 MCP 把工具供给标准化成生态级的 client/server。

一、工具:LLM 能力的扩展

1.1 一个问不出来的问题:现在几点

模型是几个月前训练完的,它不知道此刻的时间。直接问「现在几点」,好一点的模型会回答「抱歉,我无法获取当前时间」。

给它一个函数就不一样了:

python
# 最小可用的工具:返回当前时间from datetime import datetime def get_current_time():    """返回当前时间"""    return datetime.now().strftime("%H:%M")

同样的问题,LLM 这次会请求调用 get_current_time,拿到返回值后回答「现在是下午 3 点 20」。工具就是这样一些普通函数,由开发者实现并提供给 LLM。

1.2 工具使用的完整流程

一次完整的工具调用包含五个环节:输入 prompt → LLM 查看可用工具集 → 决定请求调用某个工具 → 函数返回值被喂回对话历史 → LLM 生成最终输出。

关键在第四步。函数的返回值不是直接交给用户的,而是作为对话历史的一部分回填给 LLM,LLM 看到「用户问了什么、我请求了什么、函数返回了什么」这一整段上下文,才写出最终回答。

无工具 vs 有工具:工具使用流程

1.3 用不用工具,由 LLM 决定

同样是给了 get_current_time,如果用户问的是「绿茶里有多少咖啡因」,LLM 会直接回答,不会去调用时间函数。这一点和早期硬编码的做法有本质差别:研究型 Agent 里「在这一步一定执行 web search」是开发者写死的分支,而工具调用是把工具集摆出来,由模型自己判断该不该用、用哪个。

课程幻灯片里用 LLM 上方的一个虚线框表示这层含义——这里提供了一组工具,选择权在模型。

1.4 多工具与连续决策

几个典型的「有工具答得更好」的场景:

Prompt Tool Output
找找 Mountain View 附近的意大利餐厅 web search 基于检索结果给出餐厅列表
显示买过白色墨镜的顾客 query database 查销售表中白色墨镜的成交记录
存 500 元、年利率 5%、10 年后是多少 利率计算 调用计算函数得出金额

工具使用的三个典型场景

利率那一行还有第二条路:让 LLM 直接写出数学表达式再求值,这就是后面要讲的代码执行。

工具多起来以后,一次请求可能触发一串调用。日历助手拿到「在周四找个空档,和 Alice 约个会」,手上有 check calendar、make appointment、delete appointment 三个工具,它会先调 check calendar 拿到周四的空闲时段,把结果回填后挑定下午 3 点,再调 make appointment 发出邀请,拿到发送成功的确认,最后才输出「已和 Alice 约在周四下午 3 点」。每一次调用的输出都要回填,模型才知道下一步该做什么。

日历助手多工具决策流程

二、创建工具:通过Prompt调用

2.1 LLM 不调用函数,只输出请求

今天主流模型都经过原生训练来使用工具,但把时钟拨回原生支持之前,开发者得自己写 prompt 告诉模型什么时候该用工具。

2.2 用提示词约定调用格式

做法是在系统提示里和模型约定一个暗号:

plain text

用户问「现在几点」,模型就会输出 FUNCTION: get_current_time。宿主代码扫描模型输出,发现这个全大写标记,从中取出函数名,调用真正的 get_current_time(),得到 8 a.m.,再把这个值连同之前的用户提问、函数请求一起作为对话历史交回模型,模型输出「现在是早上 8 点」。

手写 FUNCTION 协议:解析、执行、回填全链路

整条链路里,模型自始至终只做了一件事:生成一段特定格式的文本。执行、解析、回填全部由开发者的代码完成。

2.3 带参数的工具

给函数加一个时区参数,协议也跟着扩展:

python
# 按指定时区返回当前时间from datetime import datetimefrom zoneinfo import ZoneInfo def get_current_time(timezone):    """返回指定时区的当前时间"""    return datetime.now(ZoneInfo(timezone)).strftime("%H:%M")

系统提示改成「使用 get_current_time 时,输出函数名并附上时区」。问「新西兰现在几点」,模型输出 FUNCTION: get_current_time PACIFIC/AUCKLAND——它知道奥克兰是新西兰的主要城市,也知道对应的时区标识符。宿主代码取出参数、调用函数、拿到 4 a.m.,模型最终回答「新西兰现在是凌晨 4 点」。

带时区参数的协议流程

2.4 五步流程

把上面的过程抽象出来,就是工具使用的通用骨架:

  1. 提供工具:实现函数,并告诉 LLM 它可用;
  2. LLM 决定调用时,输出一段特定格式的请求;
  3. 宿主代码解析请求、调用函数、取得返回值;
  4. 把返回值回填进对话历史;
  5. LLM 基于新历史继续——生成最终回答,或者请求调用下一个工具。

全大写 FUNCTION 这套语法笨拙,全靠提示词维持,模型稍不听话就解析失败。现代模型被直接训练成用固定语法发出工具调用请求,所以这层手写协议已经退场,但五步骨架完全没变。

三、现代工具语法:框架自动生成描述

3.1 一段调用代码

先看现在的代码长什么样,再看它替你做了什么。下面用的是 aisuite——吴恩达和朋友们开发的开源库,用途是统一多家 LLM provider 的调用方式,语法与 OpenAI 的接口很接近:

python
# 把 Python 函数本身作为工具传给模型response = client.chat.completions.create(    model="openai:gpt-4o",    messages=messages,    tools=[get_current_time],    max_turns=5,)

aisuite:函数直接作为工具传入

和第二章相比,消失的是那段手写的系统提示、那段正则解析、那段手动调用和回填的代码。

3.2 docstring 自动变成 JSON Schema

aisuite 会读取函数签名和 docstring,自动生成一份描述这个函数的 JSON Schema,真正传给模型的是这份 Schema。无参数版本很简单,只有函数名和从 docstring 提取的描述:

json
{  "name": "get_current_time",  "description": "返回当前时间"}

模型靠 description 判断什么时候该调用它。工具函数的 docstring 因此不再只是给人看的注释,它是模型的判据。

docstring 自动生成 JSON Schema

3.3 max_turns:工具循环的上限

工具返回之后,模型可能决定再调一个,再返回后还可能再调。max_turns 就是这串连续调用的次数上限,用来兜住可能的死循环。一般设成 5,实践中除非任务异常复杂,很少真的触顶。

3.4 带参数的复杂 Schema

带 timezone 参数的版本,生成的 Schema 会多出参数部分:函数名、来自 docstring 的描述,再加上每个参数的名称、类型和说明。参数说明里会带上 America/New_York、Pacific/Auckland 这类取值示例,模型生成调用参数时才知道该填什么格式的时区标识符。参数描述的质量直接决定模型填对填错。

带 timezone 参数的复杂 JSON Schema

3.5 谁执行函数

用 aisuite 时,模型请求调用 get_current_time,客户端会替你把函数执行掉、把结果回填,整个循环包在这一次 create 调用里,开发者不需要写额外代码。别的接口未必如此——有些 API 要求你手动构造 JSON Schema 传给模型,也要求你手动执行函数并回填。

四、代码执行流程

4.1 每个计算器按钮都做一个工具?

假设要做一个解数学应用题的应用,先写四个工具:add、subtract、multiply、divide。用户输入「13.2 加 18.9」,触发 add,答案正确。

接着用户问「2 的平方根是多少」。加个 sqrt 工具?那指数呢?对数呢?看一眼科学计算器上有多少个按键,就知道这条路走不通。

每个运算一个工具 vs 让 LLM 写代码执行

4.2 让 LLM 写代码

换个思路:不给它算子,给它一个能跑代码的环境。提示词大致是这样:

plain text
写代码解决用户的问题。把答案写成 Python 代码,用 <executePython> 和 </executePython> 标签包起来。

问「2 的平方根是多少」,模型输出:

python
# 模型生成、被标签包裹的代码import mathprint(math.sqrt(2))

宿主代码用正则匹配起止标签,取出中间的代码执行,得到 1.4142…,把这个数值回填给模型,模型再写成一句格式良好的回答。同一套机制既能算平方根,也能算复利,还能处理复杂得多的数学问题——工具数量从「每个函数一个」压缩成了一个。

executePython:让 LLM 写代码再执行

4.3 执行方式与安全

执行环节有两条路。一是 Python 内置的 exec(),传什么执行什么,最省事,也最危险。二是放进沙箱,Docker 或 E2B 这类轻量沙箱环境都可以。

课程里提到一个真实事故:团队成员用一个自主性很高的编码 Agent,它自行决定在项目目录里执行 rm *.py,删掉了一批 Python 文件,事后还诚恳道歉说这是个愚蠢的错误。所幸代码有 GitHub 备份,没造成实际损失。

单行代码出事的概率确实不高,坦率说很多开发者不做多少检查就直接执行模型生成的代码。但最佳实践是沙箱:模型写出坏代码时,数据丢失和敏感信息泄漏的风险被限制在盒子里。

exec vs 沙箱:控制坏代码的破坏范围

4.4 错误反馈循环

代码执行失败时,把错误信息原样喂回模型,让它据此修改代码再试一两次,往往能拿到正确结果。这正是反射模式在工具场景下的形态——反思的依据不是模型的凭空推测,而是执行器给出的真实报错。代码执行的重要性高到 LLM 训练方会专门优化这项能力。

五、MCP:工具生态的标准化

5.1 集成爆炸:M×N 问题

一个应用想接入 Slack、Google Drive、GitHub 和 Postgres,开发者得分别围绕这四家的 API 写包装函数。另一个团队做另一个应用,同样的四份包装再写一遍。M 个应用、N 个数据源,社区总工作量是 M×N,而且绝大部分是重复劳动。

M×N 集成爆炸:每个应用都要接每个数据源

5.2 MCP 的解法:M+N

MCP(Model Context Protocol,模型上下文协议)由 Anthropic 提出,现已被许多公司和开发者采用。它定义了应用获取工具和数据源的标准接口,于是社区总工作量从 M×N 降到 M+N:N 个数据源各实现一个 server,M 个应用各实现一次 client 接入。

MCP 的初始设计侧重于「给 LLM 更多上下文」,早期能力多是拉取数据,文档里称之为资源(resources);此外也提供可执行动作的工具(tools)。

MCP:工作量从 M×N 降到 M+N

5.3 Client 与 Server

client 是想要工具和数据的应用,server 是把数据源或动作包装成标准接口的软件,两边的清单都在快速增长。你自己的应用可以作为 client 去消费别人的 server,也可以反过来做成 server,把资源提供给其他开发者。

MCP 生态:Clients 与 Servers

5.4 演示:Claude Desktop 接 GitHub MCP Server

课程里的演示用 Claude Desktop 作为 client,连上 GitHub MCP server。

输入「总结这个 GitHub 仓库的 README.markdown」(指向 aisuite 仓库),client 通过 GitHub server 发出请求:从 aisuite 仓库读取 README.markdown。server 返回一大段文件内容,这段内容进入 LLM 的上下文,LLM 据此写出摘要。

接着问「最近的 pull request 有哪些」,同一个 server 换了另一个工具——列出 pull request,请求参数包含仓库名 aisuite、按 updated 排序、取 20 条。返回结果回填后,LLM 写出一段 PR 情况的文字总结。

两次提问的链路完全一样,变的只是 server 端调用了哪个工具。


本文由 AgentPlanFlow 生成

觉得不错?给作者点个赞吧

分类:Agent

评论 (0)

无需注册,留言经站长审核后公开。

加载评论中…

Agentic AI (三)工具使用:从函数调用到 MCP · Yinchen 的博客