从一次抓包看 Agent 如何调用工具
当我们和一个代码 Agent 对话时,经常会看到它先说一句“我先看一下项目结构”,然后很快读文件、搜索代码、执行命令。表面上看,这像是大模型自己拥有了操作本地环境的能力;但从一次真实请求抓包来看,真正发生的事情更像是一套清晰的协议:模型负责选择动作,Agent runtime 负责执行动作。
本文基于一次 OpenCode 到 LLM 服务的请求分析,拆解 Agent 是如何识别可用工具,以及 LLM 的回复如何驱动 Agent 执行工具调用的。
这次请求里发生了什么
用户的问题是:
介绍一下这个项目
这类问题看起来是一个普通问答,但对于代码 Agent 来说,正确做法不是凭空猜测,而是先查看项目的关键文件。抓包里可以看到,请求发送到了 LLM 的 responses 接口,请求体大致包含这些字段:
{
"model": "gpt-5.5-2026-04-24",
"input": [...],
"tools": [...],
"tool_choice": "auto",
"stream": true
}
这里最关键的是 tools 和 tool_choice。
tools 是 OpenCode 在请求时提供给模型的工具清单。这次请求里一共有 68 个工具,包括 read、glob、grep、bash、apply_patch、浏览器工具、Figma 工具和一些平台相关工具。
tool_choice: "auto" 表示模型可以自行决定是否调用工具,以及调用哪个工具。
也就是说,模型并不是自己扫描本机环境后“发现”了工具。它知道有哪些工具,是因为 Agent runtime 在发起 LLM 请求时,把工具列表作为结构化 schema 一起传给了模型。
工具是如何暴露给模型的
每个工具在请求里都不是一段自然语言说明,而是一个 function schema。以 read 工具为例,它大致长这样:
{
"type": "function",
"name": "read",
"description": "Read a file or directory from the local filesystem...",
"parameters": {
"type": "object",
"properties": {
"filePath": {
"type": "string",
"description": "The absolute path to the file or directory to read"
},
"offset": {
"type": "integer"
},
"limit": {
"type": "integer"
}
},
"required": ["filePath"]
}
}
模型因此知道三件事:
- 有一个工具叫
read。 - 这个工具用于读取本地文件或目录。
- 调用它时必须传
filePath,也可以传offset和limit。
这和传统函数调用非常像。区别在于,函数本体并不在模型里,模型只看到函数签名和说明。真正的函数实现位于 Agent runtime 中。
LLM 返回的不是“命令文本”,而是结构化 function_call
这次响应里,模型先返回了一段面向用户的普通文本:
我先快速看一下仓库根目录和关键配置,再结合项目说明给你概览。
这属于 assistant message,是用户可以看到的自然语言内容。
随后,模型返回了多个独立的结构化输出项,类型是 function_call。例如:
{
"type": "function_call",
"name": "read",
"call_id": "call_nS74UCeshMSaMeqjoMauECS2",
"arguments": "{\"filePath\":\"/Users/bytedance/code/marketing_fe/package.json\",\"offset\":1,\"limit\":200}"
}
这里有几个重点:
function_call是 API 响应里的结构化 JSON 输出,不是普通文本内容。name指定要调用哪个工具。arguments是传给工具的参数。call_id用于后续把工具执行结果和这次调用配对。
这次模型一共发起了 4 个 read 调用,分别读取:
/Users/bytedance/code/marketing_fe/package.json
/Users/bytedance/code/marketing_fe/pnpm-workspace.yaml
/Users/bytedance/code/marketing_fe/README.md
/Users/bytedance/code/marketing_fe
注意,这里模型没有执行 shell 命令。它没有返回 cat package.json,也没有调用 bash。它选择的是更适合读文件的 read 工具。
这个选择来自两部分信息:一是 read 工具的 description,二是 developer prompt 里的行为约束,例如“读文件优先使用 Read,不要用 bash 的 cat/head/tail”。
谁真正执行了工具
模型本身不会读本地文件。它只返回 function_call。
真正执行动作的是 OpenCode 的 Agent runtime。执行流程可以理解为:
用户输入
-> OpenCode 组装 input + tools
-> LLM 返回 message + function_call
-> OpenCode 解析 function_call
-> OpenCode 在本地执行 read 工具
-> OpenCode 把结果作为 function_call_output 发回 LLM
-> LLM 基于结果继续推理或生成最终回答
后续请求证明了这个闭环。OpenCode 把上一轮的工具调用和本地执行结果一起放进了下一次 LLM 请求:
{
"type": "function_call_output",
"call_id": "call_nS74UCeshMSaMeqjoMauECS2",
"output": "<path>/Users/bytedance/code/marketing_fe/package.json</path>..."
}
这里的 call_id 和前面的 function_call.call_id 对应,用来告诉模型:这是哪一次工具调用的返回结果。
为什么这是 Agent 能力的关键
这次抓包说明了一个重要事实:Agent 的“会用工具”不是魔法,而是协议设计。
LLM 负责:
- 理解用户意图。
- 根据工具 schema 选择合适工具。
- 生成合法的工具参数。
- 在拿到工具结果后继续推理。
Agent runtime 负责:
- 把可用工具以 schema 形式提供给模型。
- 校验和解析模型返回的
function_call。 - 在真实环境里执行工具。
- 把执行结果包装成
function_call_output继续回传。
这种分工让模型不需要直接拥有本地权限,也能通过受控接口完成文件读取、代码搜索、浏览器操作或命令执行。
function_call 与普通回复的区别
一个容易混淆的点是:function_call 到底算不算 LLM 回复?
答案是:算模型输出,但不是普通文本回复。
普通文本通常出现在 assistant message 中:
{
"type": "message",
"role": "assistant",
"content": [
{
"type": "output_text",
"text": "我先快速看一下仓库根目录和关键配置..."
}
]
}
工具调用则是独立结构:
{
"type": "function_call",
"name": "read",
"call_id": "...",
"arguments": "..."
}
所以更准确的说法是:function_call 是 LLM API 响应里的结构化输出项,不是展示给用户的自然语言内容。
小结
这次抓包展示了一条完整的 Agent 工具调用链路:
- OpenCode 在请求里声明 68 个可用工具。
- LLM 通过
toolsschema 知道有哪些工具以及参数格式。 - 用户要求介绍项目时,LLM 判断需要先读文件。
- LLM 返回 4 个
read的function_call。 - OpenCode 本地执行这些
read调用。 - OpenCode 把结果作为
function_call_output发回模型。 - 模型再基于真实文件内容生成项目介绍。
这就是现代代码 Agent 的基本工作方式:模型不直接操作环境,而是通过结构化工具调用协议,把“想做什么”交给 runtime 执行。工具清单、参数 schema、调用 ID 和执行结果回传,共同构成了 Agent 能可靠工作的基础。