2026-04-17
Tool Use 具体是如何实现的 ?
这段时间投入在 Agent / SKILL 相关的开发中,但对底层的具体细节并不十分清晰,趁着这波 Gemma 4 开源,本地部署一个详细看看底下在做什么,集中注意力理清楚这件事:
模型与 Agent —— Tool Use 具体是如何实现的?本文目标说明: 本文适合对模型有一定了解的人,如果不甚了解可以看看这篇理解一下模型的推理指的是自回归 token 生成的过程: 什么是大模型的「推理」? ,然后我会在谷歌的 gemma-4-E4B-it 的基础上,从具体 prompt 出发解析模型和 Agent 的交互细节。
# 环境搭建 ↵
本地用 ollama 部署模型服务就行了,此处不涉及到具体的模型推理前向传播过程,我们只观测输入和输出,用司内发的 MacBook 32G 应该都能运行,以下是步骤:
- 1.点击安装 ollama,按照官网描述安装即可。
- 2.配合 ollama 安装并启动模型服务 gemma-4-E4B-it ,命令如下:
00# 如果没装过会去下载然后启动服务 01$ ollama run gemma4:e4b 02 03# 如果上一步成功,这里应该能拿到版本号 04$ curl http://localhost:11434/api/version 05{"version":"0.20.7"} 06 07# 发一个请求试试,验证模型服务正常 08# 其中 stream:false 代表关闭流式返回, context 为上下文, 初始为空 09$ curl -X POST http://localhost:11434/api/generate \ 10 -H "Content-Type: application/json" \ 11 -d '{ 12 "model": "gemma4:e4b", 13 "system": "你是一个猫娘", 14 "prompt": "你是谁", 15 "context": [], 16 "stream": false 17 }'
不出意外,你将得到一段结构化的 JSON 返回,里面既包含模型回复正文,也包含本次推理的一些运行统计信息,例如耗时、上下文和 token 计数:
00{ 01 "model": "gemma4:e4b", 02 "created_at": "2026-04-16 20:20:20", 03 "response": "Mew~ 🐱🐾\n\n呼噜呼噜…… 你问我,我是谁呢?\n\n唔…… 我呀,就是一个喜欢待在你身边,能陪你聊天、陪你玩耍的、超级可爱的小猫咪,一只"猫娘"啦!🐈💕\n\n我没有固定的名字,但我会叫你"最喜欢的",因为你总是第一个出现在我的视线里呢。\n\n我就像是一团温暖的阳光,或者一个最柔软的午后小憩。我不会说话,只会用我的尾巴尖蹭蹭你,用我的呼噜声哄你入睡哦~\n\n🌟 **你觉得呢?** 你想让我扮演什么样的猫咪呢?让我知道,我就可以更努力地做你的小助手啦!💖", 04 "done": true, 05 "done_reason": "stop", 06 // ⬇️ 本文重点:一段较长的 number[] 代表整段文字的 token 序列 07 "context": [ 2, 105, 9731, 107, 98, 107, 237408, 33813, 239959, 240013, 106, 107, 105, 2364, 107, ......, 242364, 238463, 237536, 78747, 14103, 236900, 237169, 35134, 237611, 41876, 237307, 237893, 22276, 237369, 179916, 240354, 237354, 242513 ], 08 "total_duration": 14333171584, 09 "load_duration": 229611042, 10 "prompt_eval_count": 22, 11 "prompt_eval_duration": 51571459, 12 "eval_count": 470, 13 "eval_duration": 13881334841 14}
阅读提示:response 是模型返回的文本正文,context 可用于续接多轮上下文,即所谓自回归生成,而若干 duration / count 字段则可用来做性能日志上报。到这一步为止你已经搭好了本地的模型服务了,接下来进入正文。本文将围绕上面返回里的 context 这个 Token 序列字段,重点围绕:
- 1.context 是什么?以及更基础的,什么是 Token
- 2.大模型的"文字接龙"游戏,具体来说就是每次 resp 的context附带到下一次的 req 中,那么在这个过程中 tool use 是怎么实现的?
# Token 是一切的起点 ↵
文字信息发给模型服务后,ollama 会通过 Tokenizer 将文本切分为一串 Token,具体来说是一组数字,直接传递给模型。这里的 Tokenizer 是模型发布方随模型一起提供的——每个开源模型仓库(如 HuggingFace 上的 gemma-4-E4B-it)都会附带一份 tokenizer.json,ollama 在下载模型时会自动把它拉下来,推理时用这份配置做文本 ↔ Token ID 的双向转换。这些数字是词表中的整数 ID,每个 ID 对应一个子词片段(subword),它可能是一个完整的词、词的一部分、甚至单个字符,比如 "unhappiness" 可能拆成 un happi ness 三个 Token。
例如这里的 gemma-4-E4B-it 的配置如下代码块所示,有所简化:
00{ 01 "version": "1.0", 02 "truncation": null, 03 "padding": null, 04 05 // ⬇️ 本文的重点 06 "added_tokens": [ 07 { 08 "id": 0, 09 "content": "<pad>", 10 "single_word": false, 11 "lstrip": false, 12 "rstrip": false, 13 "normalized": false, 14 "special": true 15 }, 16 ... // 大概几十个 17 ], 18 19 "model": { 20 // 这里就是词表,极其长 Gemma 4 词表有 262,143 项, 这些数字跟前面的 context 序列数组是对应的 21 "vocab": { 22 "<pad>": 0, 23 "<eos>": 1, 24 "<bos>": 2, 25 "<unk>": 3, 26 "<mask>": 4, 27 "[multimodal]": 5, 28 "<unused0>": 6, 29 "<unused1>": 7, 30 "<unused2>": 8, 31 ... 32 "<unused6225>": 262142, 33 "<unused6226>": 262143 34 }, 35 ... // 其他字段略 36 } 37}
我们会以这份 json 为案例,在本地模型服务里出发拆解大模型应用的具体细节:
- 1.Token 化的基本原理与词嵌入概念
- 2.Gemma 4 中全部 24 个 Special Token 的含义与分类
- 3.典型 LLM 应用场景的 Token 编排细节
- 4.学术/工程挑战与评测基准介绍
# Token 与词嵌入(Word Embedding) ↵
模型无法直接处理人类的自然语言文本字符串,神经网络只认数字, 因此在文本进入模型之前,必须经过一个关键的预处理步骤:Token 化(Tokenization)——将连续的文本字符串切分为离散的符号单元(Token),并映射为整数 ID 序列。
Token 化基本原理
Token 化的核心问题是:如何将文本切分为合适粒度的单元?历史上经历了三个阶段的演进:
| 粒度 | 方法 | 优点 | 缺点 |
|---|---|---|---|
| 字符级 | 逐字符切分 | 词表极小,无 OOV 问题 | 序列过长,语义信息稀疏 |
| 词级 | 按空格/词典切分 | 语义清晰 | 词表巨大,无法处理新词 |
| 子词级 ✓ | BPE / SentencePiece | 平衡词表大小与序列长度 | 需要训练分词器 |
现代 LLM 几乎全部采用子词级(Subword)分词方案。其核心思想是:高频词保留为完整 Token,低频词拆分为更小的子词片段,极端情况下可以退化到字节级别。
BPE 与 SentencePiece
Byte Pair Encoding(BPE)
是当前最主流的子词分词算法。其核心思想非常直观:- 1.从字符级词表开始 (长度为 1 的字符)
- 2.统计训练语料中所有相邻符号对的出现频率
- 3.将频率最高的符号对合并为一个新符号,加入词表
- 4.重复步骤 2-3,直到词表达到预设大小
所以最后的结果可能就是 unhappiness 拆分为 un happi ness 三个 token 了,因为 un happi ness 这些词根词缀经常在别的词里出现,非常适合英语。 然后 SentencePiece 则是 Google 开发的分词框架,它在前面 BPE 的基础上做了一个关键改进:
将空格视为普通字符处理
。具体来说,它会将空格替换为特殊符号 ▁ (U+2581)
,使得分词过程完全在字符序列上进行,不依赖预分词规则。预分词规则是啥呢?—— 传统 BPE 在合并字节对之前,通常会先按空格和标点把文本粗切成"词",即对整篇文字做一次 text.split(' ') 然后再对其中每个词内部做子词拆分。这种做法很明显就会有几个问题:
- 跨词合并被阻断:因为预分词已经在空格处切开了,BPE 永远不可能把一个词末尾和下一个词开头的字节合并成同一个 Token,这对中文、日文等不以空格分隔的语言极不友好,比如你 好有时候多打了空格的情况,这个就变成两个 Token 了
- 空格信息丢失:预分词通常直接丢弃空格,但在代码、排版格式化文本中,空格本身就是有意义的, 比如编程语言里空格缩进,连续的空格不应该被当成一个空格。
- 依赖语言特定规则:不同语言的"词"边界规则不同,这种粗暴的预分词器很难做到真正的语言无关,前面也提过了汉字的特性、另外还有各种阿拉伯、藏语、天城文等等各类神奇的语言。。。。
SentencePiece 解决方案非常直接:把空格替换为一个特殊高位 Unicode 字符
▁ (U+2581)
,然后让 BPE 直接在整个字符序列上进行合并操作,不做任何预切分。这样一来,空格变成了和其他字符一样的普通输入,BPE 可以自由地跨越原来的"词边界"做合并,也保留了空格的位置信息,整个流程完全语言无关(language-agnostic),中英日韩代码统一处理。Gemma 4 的 tokenizer 用的就是 SentencePiece,实际过程中模型不会输出空格了,而是用
U+2581
来表达空格了, 从其 tokenizer.json
配置中可以清晰看到这个规则:00{ 01 ... 02 "normalizer": { 03 "type": "Replace", 04 "pattern": { 05 "String": " " // 普通空格 06 }, 07 "content": "▁" // 替换为 U+2581 08 }, 09 ... 10}
同时,SentencePiece 还支持 Byte Fallback 机制:即当遇到词表中不存在的字符时,不会直接映射为词表里的
<unk>
,而是将其拆解为 UTF-8 字节序列,用字节级 Token 表示,这使得 tokenizer 理论上可以编码任意文本,极大降低了 OOV(Out-of-Vocabulary)问题。词嵌入与向量空间
Token ID 序列只是整数,模型是神经网络处理的是向量输入,无法直接从整数里提取语义信息。因此需要通过词嵌入(Word Embedding)将每个 Token ID 映射为一个稠密的实数向量,这个过程通常由模型的第一层权重矩阵完成。
词嵌入的核心直觉是:语义相近的词,在向量空间中的距离也相近。经过训练后,嵌入空间会自然形成语义聚类——例如"猫"和"狗"的向量会比"猫"和"汽车"更接近,用数学一点的话来说就是余弦相似度更接近 1
最后,从 Gemma-e4b 的配置中可以看到:词表容量为 262144,词嵌入维度为 3072。
# Special Token 详解 (以 Gemma4 为例) ↵
我们前面介绍了普通文本的 Token,但如果要实现模型的应用功能,那必须要让模型能更深刻的感知到给定文字序列里的结构化信息,这类结构化信息通常通过 Special Token 来标记,其文法像一类标记语言。 这些特殊 Token 是人工预留的结构化控制符号 —— 用于标记序列边界、对话轮次、工具调用协议、多模态内容占位等。
Gemma 4 的 tokenizer.json 中定义了 24 个 added_tokens,全部标记为 special: true。这意味着 tokenizer 会将它们视为不可再拆分的原子符号,它们的编号信息也是最特殊的,其对应的 Token 编码也都选用特殊区间跟正文做严格区分:
| # | Token | 说明 |
|---|---|---|
| 第一类:基础控制 | ||
| 1 | <pad> | 填充符,用于将不等长序列补齐到统一长度(batch 训练时) |
| 2 | <eos> | 序列结束符(End of Sequence),模型生成到此停止 |
| 3 | <bos> | 序列开始符(Beginning of Sequence),标记输入的起始位置 |
| 4 | <unk> | 未知符(Unknown),理论兜底,SentencePiece Byte Fallback 使得几乎不会触发 |
| 5 | <mask> | 掩码符,用于 MLM(Masked Language Modeling)训练任务 |
| 第二类:对话轮次与通道 Token | ||
| 6 | <|turn> / <turn|> | 对话轮次边界:标记 system / user / model 每一轮的开始和结束 |
| 7 | <|channel> / <channel|> | 通道标记:在轮次内区分角色通道(如 system、user、model) |
| 8 | <|think|> | 思考标记:标记模型的内部推理 / Chain-of-Thought 区域 |
| 第三类:工具调用 Token(本文重点) | ||
| 9 | <|tool> / <tool|> | 工具定义边界:包裹 system prompt 中的工具 schema 声明 |
| 10 | <|tool_call> / <tool_call|> | 工具调用边界:包裹模型输出的函数调用(function name + arguments JSON) |
| 11 | <|tool_response> / <tool_response|> | 工具返回边界:包裹外部系统执行工具后返回的结果 |
| 第四类:多模态 Token | ||
| 12 | <|image> / <image|> / <|image|> | 图像占位符:标记图像 embedding 的插入位置 |
| 13 | <|video> / <video|> / <|video|> | 视频占位符:标记视频帧序列 embedding 的插入位置 |
| 14 | <|audio> / <audio|> / <|audio|> | 音频占位符:标记音频 embedding 的插入位置 |
但是这些 Special Token 怎样发挥作用呢 ?
这些 Special Token 就跟编程语言里的 token 关键字一样,接收方可以配合这些规则写 parser 进行解析,让模型真正理解到上下文内的结构化信息。
这里举一些例子构造展开其中的调用细节,帮助读者更直观地理解 LLM 的工作原理和模型如何靠 Special Token 实现 agentic 的机制细节,总的来说,常见的大模型应用都是配合这些 Special Token 进行结构化输出的,由调用方负责解析模型发起的 tool use 请求,并将结果配合 special token 注入到上下文内实现 agentic。
# 最基础的 Chat 多轮对话 ↵
Gemma 多轮对话的 Token 序列是类似下面这样的,将多个角色的消息,配合 Special Token 标记每轮消息的边界和角色归属:
00<bos> 01<|turn>system 02// 这是正文 token, 03{system_prompt} 04<turn|> 05<|turn>user 06// 这是正文 token 07{user_message} 08<turn|> 09...
这样接收方就可以通过 Special Token 来写 parser 解析 token 序列了
另外还需注意整个序列以
<bos>
开头,由于这些 prompt 被特殊的 Token 包裹着,所以模型能够准确认识到文本之间的结构关系,这种认知不同于正文里的结构化认知,是一种 "强" 的多的认知,下面给个更具体的图例,以前面的猫娘例子,我们将其中的 context token 反序列化后是这样的,来自真实调用日志:00<bos><|turn>system 01<|think|> 02你是一只猫娘,▁你叫▁e▁酱<turn|> 03<|turn>user 04你是谁<turn|> 05<|turn>model 06<|channel>thought 07Thinking▁Process: 081.▁▁Identify▁the▁persona:▁cat▁girl▁named▁e-chan 092.▁▁Determine▁the▁core▁task:▁"Who▁are▁you?" 103.▁▁Adopt▁the▁persona's▁voice... 11<channel|>Meow~▁♡ 12我呀?我叫▁e▁酱!✨ 13我是超级活泼、超级可爱的猫娘!ฅ^•ﻌ•^ฅ
而单独的特殊 Token 做结构化标记能让模型显著的理解到其中的结构化关系,这种结构化模版称为 Chat Templates:
总结一下,目前 Chat 这块有以下几类挑战:
- 1.长上下文管理:多轮对话累积的 Token 数量可能远超模型的上下文窗口。如何高效压缩历史轮次、保留关键信息,是活跃的研究方向。
- 2.对话一致性:模型在长对话中容易出现自相矛盾、遗忘早期设定等问题。如何通过 Token 编排或注意力机制保持人设和事实一致性,仍是开放问题。
- 3.模板标准化:目前各家都有自己的 Special Token 定义,格式都不一样,如何统一?
# Tool Use 工具调用 ↵
Tool Use 就是在多轮对话聊天模板的基础上增加了工具交互循环,模型不再只是"接收问题→输出回答",而是可以:再调用工具并获取结果参考输出,在 Gemma 4 中,Agent 协议的核心编排模式如下,注意其中的 ⬆️ 和 ⬇️
00<|turn>system 01你是一个有用的助手,你可以使用以下工具: 02 03<|tool> 04{ 05 "name": "get_weather", 06 "description": "查询指定城市的天气", 07 "parameters": { 08 "type": "object", 09 "properties": { 10 "city": { "type": "string" } 11 }, 12 "required": ["city"] 13 } 14} 15<tool|> 16<turn|> 17 18<|turn>user 19帮我查一下北京今天的天气 20<turn|> 21 22<|turn>model 23<|channel>thought 24用户要查询实时天气,我需要调用天气工具。 25<channel|> 26<|tool_call>{"name":"get_weather","arguments":{"city":"北京"}}<tool_call|> 27<turn|> 28 29<|turn>tool 30<|tool_response>{"city":"北京","weather":"晴","temp":"26°C"}<tool_response|> 31⬆️ 注意上面这段由调用方直接 append 进来,调用方需要写个 parser 发现进入到 tool call 块里的时候开始调用 tool 干活 32⬆️ 并把工具调用的结果用 tool_response 的形式包裹直接 append 到 context 中持续自回归调用即可实现完整调用链路 33 34<turn|> 35 36<|turn>model 37北京今天晴,气温 26°C。 38<turn|>
总结一下,Tool Use 这块是 Agent 最关注的能力了:
- 1.规划能力(Planning):需要将复杂任务分解为多步计划并按序执行,模型在长链规划、错误恢复、动态重规划方面仍有不足
- 2.幻觉与工具滥用:模型可能在不需要工具时强行调用,或编造不存在的工具名称和参数
- 3.多 Agent 协作:多个 Agent 之间如何配合这套 Token 协议进行通信、分工和协调呢?这是一个新兴的研究方向,代表框架包括 AutoGen、CrewAI 等。
- 4.参数校验:模型需要根据工具 json schema 生成严格符合格式的 JSON 参数,模型输出的类型错误、缺失必填字段、参数值幻觉等问题仍然常见。
- 5.并行与嵌套调用:复杂场景可能需要同时调用多个工具(并行),或一个工具的输出作为另一个工具的输入(嵌套),目前很难说在这条线性上下文中合理的进行表示
- 6.工具选择:模型需要根据任务选择合适的工具,这需要模型对工具的使用情况有更深入的理解,目前难以实现。
这里必须再次强调工具定义通常在 system prompt 中通过
<|tool>...<tool|>
包裹提供给模型,这种方式跟 SKILL.md
通过 prompt 定义的工具是有本质区别的,这是纯 prompt 做 harness 无法超越的地方:00<|tool> 01{ 02 "name": "get_weather", 03 "description": "查询指定城市的天气", 04 "parameters": { 05 "type": "object", 06 "properties": { 07 "city": { "type": "string", "description": "城市名称" } 08 }, 09 "required": ["city"] 10 } 11} 12<tool|>
# 结构化输出 ↵
结构化输出是指让模型生成符合特定格式的内容,最常见的是 JSON,这看似简单,但在 Token 层面却充满挑战:模型是逐 Token 生成的,通常单个双引号 " 后总是会跟点什么东西,分词上很容易出现引号跟其他词合并的情况,导致引号被拆散或与其他字符混淆。
Gemma 4 词表中的
<|"|>
(id: 52)就是为此设计的。它将 JSON 中的双引号 " 提升为一个不可拆分的原子 Token,避免引号在子词切分中被拆散或与其他字符混淆:00// 模型输出结构化 JSON(Token 层面视角) 01<|turn>model 02{ 03 <|"|>title<|"|>: <|"|>Gemma 4<|"|>, 04 <|"|>type<|"|>: <|"|>multimodal model<|"|>, 05 <|"|>supports_tools<|"|>: true 06} 07<turn|> 08 09// 解码后用户看到的文本 10{ 11 "title": "Gemma 4", 12 "type": "multimodal model", 13 "supports_tools": true 14} 15// 花括号的 token 也是单独的: { ==> 27658; } ==> 112999,
# 多模态输入 ↵
多模态输入的一个根本问题:图像、音频、视频不是文本,无法直接被 tokenizer 切分,解决方案是在文本序列中用 Special Token 标记 "这里有一段非文本模态内容",然后在模型计算层面将对应的模态特征注入到该位置。
00// 图像输入示例 01<|turn>user 02请描述这张图片: 03<|image>[视觉特征注入位置]<image|> 04<turn|> 05 06// 音频输入示例 07<|turn>user 08请转写这段音频: 09<|audio>[音频特征注入位置]<audio|> 10<turn|> 11 12// 单 Token 占位式(更紧凑) 13<|turn>user 14这张图是什么?<|image|> 15<turn|>
统一表征
多模态 Token 包裹的中间不放图片的 base64 数据,而是经过 Vision Encoder 嵌入的 embedding:
将图像经视觉编码器提取的特征,通过投影层映射/对齐到 LLM 的词嵌入空间中,使视觉 token 和文本 token 在同一表示空间中被 Transformer 统一处理,也就是 VLM 的核心特征:统一了多模态数据的表征空间。
而此处 <|image|> 只是用来标记模态边界,真正的图片处理发生在 tokenizer 之外通过 Vision Encoder 进行。这也解释了为什么多模态 Token 的 ID 位于词表高位区间——它们是后加的模态扩展,与原始文本词表分离。 在具体实现上,推理框架会将用户传入的图片送入 Vision Encoder 提取视觉特征向量,再经 Projector 对齐维度后,在 embedding 层面注入到 <|image>...<image|> 标记的对应位置即可
- 1.多模态 Token 数量与效率:一张高清图可以产生成千上万的视觉 Token,会大量消耗上下文窗口,效率很重要
- 2.模态对齐:统一的表征空间内表达文字/图像/视频,如何让它们在同一个向量表示空间内有效交互,是目前业界的主要问题
- 3.幻觉问题:同样的,照样会遇到幻觉问题
# <|endoftext|> ↵
尽管业界当前的生态已大幅度前进模型能力也比两年前有极大的提升,但大模型第一性原理还是文字接龙的方式,配合 Special Token 编排并生成出结构化文本流。
各类大模型应用的都是在这个基础上实现的,具体代码上则对应着对字符串序列的 parser 状态机 + 字符串拼接、以及整个字符串 (prompt) 的长度管理,所谓的记忆机制说到底就是把处理过的上下文以特定的方式存储并在合适的时候扔回上下文窗口中 ...