1. 端侧小模型工具调用时XML 标签为什么会“漏”进正文先把问题说清楚。所谓“2B 模型在端侧做工具调用输出里混进 XML 标签”典型表现是这样的你让模型判断“现在要不要调用某个工具”它本该只吐出一个结构化的调用意图结果它把tool_call、function、arg这类标签连同标签里的内容甚至标签外的解释性文字一股脑全塞进了最终回复里。用户看到的就是一段半结构化、半自然语言的“缝合怪”。这个现象在端侧尤其常见原因不神秘但很多人排查方向错了。云端大模型做工具调用时背后往往有一整套成熟的约束机制专门的对话模板、经过对齐训练的函数调用格式、服务端的输出解析器、以及足够大的参数量来“记住”格式。而端侧 2B 级别的模型参数量被压缩到极致量化之后权重精度进一步下降它对“格式边界”的感知能力天然就弱。更关键的一点是工具调用本质上是一个“格式遵循”任务而不是“知识问答”任务。2B 模型在知识问答上可能表现尚可但格式遵循极度依赖训练数据的分布和推理时的约束。端侧部署时我们通常用的是通用对话模型而不是专门做过 function calling 微调的模型。你让它输出 XML它知道 XML 长什么样但它不知道“什么时候该停”“标签外不该有内容”“标签必须闭合”这些隐含规则。还有一个容易被忽略的点端侧推理框架的采样策略。很多端侧推理引擎默认用较高的 temperature 或者 top-p为了让回复“自然”。但工具调用需要的是确定性输出temperature 应该压到接近 0。采样一随机模型就可能在标签后面“顺手”补一句“好的我将为您调用……”这句话就混进正文了。我实测过一个 2B 模型在端侧做天气查询工具调用prompt 里明确写了“只输出 XML不要输出其他内容”结果十次里有三次会在/tool_call后面多一句“希望以上信息对您有帮助”。这不是模型“不听话”而是它的输出分布里“帮助性回复”的权重太高压过了格式约束。2. 从 token 生成机制看标签和正文为什么会被“粘”在一起要真正理解这个问题得从自回归生成的基本机制说起。模型是一个 token 一个 token 往外吐的它每一步都在算“下一个 token 最可能是什么”。工具调用的 XML 结构对模型来说就是一串特定的 token 序列。问题出在两个地方。第一标签的 token 化方式。tool_call这种字符串在不同 tokenizer 下可能被切成、tool、_、call、五个 token也可能被切成tool、_call两个 token。如果训练数据里这种标签出现得少tokenizer 就没有为它优化模型生成时对“标签边界”的感知就是模糊的。它可能生成tool_call之后觉得“这里应该接一个换行或者一句解释”因为它在训练时见过太多“标签后面跟自然语言”的样本。第二停止条件的设计。云端 API 做工具调用时服务端会在解析到完整 XML 后强制截断模型根本不需要自己“知道什么时候停”。但端侧如果只是简单地把模型输出直接透传给用户没有解析层那模型生成到哪就是哪。模型没有“我已经输出完工具调用了该停了”的显式信号它只会按照语言模型的惯性继续往下写。这里有个很实用的类比模型就像一个特别爱聊天的助手你让它“写个便签”它会写便签但写完之后它觉得“顺便再口头说一句”更礼貌。云端方案是有人在旁边盯着便签一写完就把纸抽走端侧方案是没人盯助手就把便签和口头补充一起递给你了。所以解决思路的核心不是“让模型更聪明”而是在模型外面加一层“格式守门人”。这层守门人要做三件事约束生成、解析输出、兜底清洗。下面几节我会把这三件事拆开讲。3. 约束生成让模型在“该停”的地方停下来最直接有效的手段是约束解码也就是 constrained decoding。它的原理是在每一步采样时动态屏蔽掉不符合格式的 token。比如当前已经生成了tool_call那么下一个 token 只允许是标签内合法的字符不允许是“好的”“希望”这类自然语言开头。端侧实现约束解码不一定需要复杂的语法引擎。对于 XML 工具调用这种固定结构可以用一个轻量的状态机。状态机维护“当前在标签外还是标签内”“当前标签是否闭合”“是否已经输出了完整的调用块”这几个状态。每步采样前根据状态计算一个允许的 token 集合把其他 token 的 logit 设成负无穷。我实际用过的做法是先用 tokenizer 把允许的字符映射成 token id 集合然后在推理循环里做 mask。对于 2B 模型词表大概几万每步做一次集合过滤开销可以接受。关键是状态机要写得足够细比如arg namecity这种带属性的标签属性名和属性值要分开约束。另一个更轻量的方案是停止符注入。在 prompt 里明确告诉模型输出完/tool_call后必须输出一个特殊停止符比如|end|。然后在推理时把这个停止符加入 stop token 列表。这样模型一旦生成停止符推理就终止后面的内容根本不会产生。这个方案实现成本极低但依赖模型对停止符的遵循能力。2B 模型有时会忘记输出停止符所以最好和约束解码结合使用。还有一个细节prompt 里的格式示例要给足。很多人写 prompt 时只写“请用 XML 格式输出”但不给具体示例。2B 模型对抽象指令的理解能力弱但对 few-shot 示例的模仿能力强。你给它两三个完整的“输入-输出”示例每个示例里标签外没有任何多余文字它模仿出干净输出的概率会明显提升。我一般会给三个示例覆盖“需要调用”“不需要调用”“调用多个工具”三种情况。4. 输出解析把“混在一起”的内容拆干净即使做了约束生成也不能保证 100% 干净。端侧环境复杂量化误差、采样波动、prompt 被截断都可能导致格式跑偏。所以解析层是必须的不是可选的。解析层的第一件事是定位工具调用块。不要假设模型输出一定以tool_call开头。实际输出可能是“好的我来查询天气。tool_call.../tool_call”这种。所以解析器要用正则或者字符串查找把tool_call到/tool_call之间的内容提取出来标签外的文字单独处理。第二件事是校验 XML 合法性。提取出来的块要用 XML 解析器试着解析。如果解析失败说明标签不闭合或者嵌套错误。这时候不能直接把原始文本丢给用户而是要走兜底逻辑要么重试一次生成要么返回一个“工具调用格式异常”的结构化错误让上层决定怎么处理。第三件事是处理“标签混入正文”的残留。有时候模型会在标签内写自然语言比如arg namecity北京今天天气怎么样/arg。这种其实还算合法 XML但语义上 city 的值不该是一句话。解析器需要做字段级的校验比如 city 字段只允许短字符串超过一定长度就判定为异常。我踩过的一个坑是模型输出了嵌套的tool_call外层没闭合内层闭合了。正则匹配会匹配到内层的闭合标签导致提取出来的内容不完整。后来我改成用栈来匹配标签遇到开标签入栈遇到闭标签出栈栈空时才认为一个完整的调用块结束。这个改动之后嵌套异常的处理稳了很多。解析层还有一个作用是统一输出格式。不管模型吐出来的是 XML、JSON 还是某种混合格式解析层都把它转成内部统一的结构体再交给上层业务。这样上层代码不需要关心模型今天心情好不好、用了什么格式。5. 端侧部署时量化对格式遵循的隐性伤害这一点很少有人专门讲但我在实际项目里感受很深量化会显著削弱模型的格式遵循能力。2B 模型在端侧通常要量化到 4bit 甚至更低才能塞进有限的内存和算力预算。量化本质上是把权重从高精度浮点压到低精度整数这个过程会引入误差。对于知识类任务这种误差影响不大因为知识是冗余的。但对于格式遵循这种“精确序列”任务误差的伤害是放大的。具体表现是全精度下模型能稳定输出闭合标签4bit 量化后闭合标签的生成概率会下降模型更容易“忘记”写/tool_call。或者标签内的属性名会拼错比如把name写成nane。这些错误在全精度下很少见量化后就冒出来了。我的应对策略有三个。第一量化时保留关键层的精度。不是所有层都一视同仁地压到 4bitembedding 层和最后几层对格式影响大可以保留 8bit。第二在 prompt 里增加格式强调。量化后模型对指令的敏感度下降prompt 里要把格式要求写得更直白甚至重复两遍。第三解析层做容错。比如属性名拼写错误可以用编辑距离做模糊匹配nane自动纠正成name。还有一个实测经验量化后的模型对 temperature 更敏感。全精度下 temperature 0.3 还能保持格式量化后最好直接设成 0 或者 0.1。端侧推理引擎的默认采样参数往往偏“活泼”一定要手动改掉。6. 一套可复现的端侧工具调用清洗流程把前面几节串起来我给出一套我在端侧项目里实际用过的流程。这套流程不依赖特定推理框架核心逻辑可以移植。第一步prompt 构造。系统提示里明确写“你是一个工具调用助手只输出 XML不输出任何解释性文字”。然后给三个 few-shot 示例示例里标签外零文字。最后在用户输入后面追加一句“请直接输出工具调用 XML”。第二步约束解码配置。如果推理框架支持 logit 过滤挂上 XML 状态机。如果不支持至少配置 stop token 列表把/tool_call和自定义停止符加进去。temperature 设 0top-p 设 1.0避免随机采样。第三步生成后解析。用栈匹配提取完整的工具调用块。对提取出的 XML 做合法性校验。校验失败则触发重试重试时把 temperature 再压低并在 prompt 里追加“上次输出格式错误请只输出合法 XML”。第四步字段级清洗。对每个参数做类型和长度校验。字符串参数去掉首尾空白和换行。如果参数字段里混入了自然语言用规则或者小分类器判断是否异常。第五步兜底回复。如果重试后仍然失败不要让原始输出直接见用户。返回一个结构化的“工具调用失败”信号由上层决定是降级到纯文本回复还是提示用户重试。这套流程里第三步的栈匹配和第四步的字段清洗是最容易被省略的但恰恰是它们决定了最终体验。我见过太多项目只做了第一步和第二步然后抱怨“模型还是不稳定”。实际上模型永远不可能 100% 稳定稳定性是靠后面的工程层兜出来的。7. 几个容易踩的坑和对应的排查思路第一个坑prompt 里的示例本身就不干净。很多人从网上抄示例示例里标签外带了“好的”“以下是”这种前缀。模型会完美模仿这个坏习惯。排查方法很简单把你的 few-shot 示例单独拿出来看标签外是不是真的一个字都没有。第二个坑停止符和 tokenizer 不匹配。你设了|end|作为停止符但 tokenizer 把它切成了、|、end、|、五个 token推理引擎的 stop 逻辑是按 token 序列匹配的可能匹配不上。解决办法是先用 tokenizer 确认停止符的 token 序列然后在引擎里配置完整的序列。第三个坑解析层把合法输出误判为异常。比如模型输出了自闭合标签arg namecity value北京/你的解析器只认arg.../arg这种成对形式就会误判。排查时要把解析器的日志打全看看它到底在哪个标签上失败了。第四个坑多工具调用时顺序错乱。模型可能先输出工具 B 的调用再输出工具 A 的但业务逻辑期望按 A、B 顺序执行。这个不是格式问题是语义问题。解决办法是在解析后按工具名排序或者在 prompt 里明确要求按特定顺序输出。第五个坑端侧内存不足导致 prompt 被截断。2B 模型端侧部署时上下文窗口往往有限。如果你的 few-shot 示例太长加上用户输入后超了窗口前面的系统提示可能被截掉格式约束就丢了。排查方法是打印实际送入模型的完整 prompt确认没有被截断。8. 关于“要不要微调”的判断最后聊一个决策问题遇到格式混入是该做工程清洗还是该微调模型我的判断标准是看错误率和业务容忍度。如果错误率在 5% 以下工程清洗完全够用成本低、迭代快。如果错误率超过 20%而且业务对格式要求极严那可以考虑做轻量微调。端侧 2B 模型的微调不需要全参数微调LoRA 就够了。准备几百条“输入-干净 XML 输出”的样本训练一两个 epoch格式遵循能力会有明显提升。但微调有个代价模型体积会增加LoRA 权重而且换一个工具集就要重新准备数据。所以我的建议是先用工程手段把能解的题解掉微调作为最后的手段。还有一个折中方案用规则做后处理用微调做前处理。前处理指在 prompt 里注入更强的格式约束后处理指解析清洗。两者结合大部分场景都能覆盖。真正需要微调的往往是那些工具 schema 特别复杂、参数嵌套特别深的场景。我个人在端侧项目里的体会是不要指望 2B 模型“学会”工具调用格式要把它当成一个“有格式倾向但会犯错”的组件外面包一层足够健壮的工程壳。壳写得好2B 模型也能跑出稳定的工具调用体验。壳写得糙再大的模型也会在端侧翻车。