ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

本地大模型工具调用被截断仍可解析的工程实践

本地大模型工具调用被截断仍可解析的工程实践 1. 本地大模型工具调用被截断还能解析这件事到底难在哪做本地大模型推理的人迟早会撞上一个让人抓狂的场景模型明明已经开始输出工具调用了JSON 结构写到一半token 上限到了输出戛然而止。前端拿到一段残缺的文本解析器直接抛异常整个对话链路断掉。更糟的是有些推理引擎在截断时连已经生成的部分都不返回直接给你一个空字符串或者一个报错前面几十秒的推理算力全部白费。这个项目的标题说的就是这件事Local LLM engine where a tool call cut by the token limit still parses。翻译成大白话——一个本地大模型推理引擎当工具调用因为 token 限制被切断时仍然能够被正确解析。关键词是 Local LLM、tool call、token limit、parses四个词缺一不可。它解决的不是模型能力问题而是工程链路的健壮性问题。先说说这个问题的适用人群。如果你只是用云端 API 做做聊天机器人可能感受不深因为云端服务商通常会在 token 超限时返回明确的错误码比如热词里提到的api error: 400 invalid request: your request exceeded model token limit: 262这种错误至少是干净的、可捕获的。但本地推理完全是另一回事你跑的是 llama.cpp、Ollama、vLLM 或者自己写的推理循环token 预算由你自己控制截断发生在生成过程中而不是请求发起时。这时候工具调用的 JSON 可能被切在任意位置——切在键名中间、切在字符串中间、切在嵌套对象中间甚至切在转义字符的反斜杠后面。我见过太多团队在这个环节翻车。他们的做法通常是让模型输出 JSON然后用json.loads解析解析失败就重试。重试意味着重新推理本地机器上跑一次 7B 模型可能要十几秒跑 70B 模型可能要几分钟。用户等不起GPU 也耗不起。更麻烦的是如果截断是系统性的——比如你的 max_tokens 设置得偏小或者工具调用的 schema 特别长——那重试多少次都一样永远卡在同一个位置。所以这个项目的核心价值在于把截断从一个致命错误降级为一个可恢复的中间状态。它要求引擎在生成阶段就具备对工具调用结构的感知能力在 token 预算耗尽时不是粗暴地停止而是尽可能让已生成的部分保持可解析或者可修复。这背后涉及几个层面的设计生成时的结构约束、截断时的边界处理、解析时的容错策略以及修复时的补全逻辑。我接下来会把这套东西拆开讲。先讲整体设计思路为什么不能简单靠重试解决再讲核心细节包括 token 预算怎么分配、工具调用的结构怎么约束、截断点怎么处理然后是实操环节给出一套可以直接参考的实现方案最后是常见问题和排查技巧这些都是我在实际项目里踩过的坑。2. 整体设计思路为什么重试不是答案2.1 截断问题的本质是预算分配问题很多人把 token limit 当成一个单纯的数字设个 4096 就完事了。但实际上token 预算是一个需要精细分配的资源。一次完整的工具调用请求token 消耗分布在好几个地方系统提示词、工具 schema 描述、对话历史、用户当前输入、模型思考过程如果开了 reasoning、工具调用的 JSON 输出、以及工具执行后的结果回填。热词里那个exceeded model token limit: 262很说明问题——262 这个数字小得离谱大概率是某个环节的预算被卡死了。可能是工具 schema 太长可能是对话历史没做裁剪也可能是 max_tokens 参数设得太保守。当总预算不够时模型生成到一半就被硬停工具调用的 JSON 自然残缺。我的设计思路是不要把 token limit 当成一个全局的硬墙而是当成一个分层的预算体系。给工具调用输出预留一个最小可解析窗口这个窗口的大小取决于你的工具 schema 复杂度。一个简单的单参数工具可能 50 个 token 就能表达完整一个嵌套三层、带数组和枚举的工具可能需要 300 个 token 才能写出最小合法结构。引擎需要在生成前就知道这个窗口有多大并且在预算分配时优先保障它。注意最小可解析窗口不是拍脑袋定的要根据你的工具 schema 实际 token 化后的长度来算。同一个 schema用不同的 tokenizer 算出来的长度可能差 20% 以上必须用你实际使用的模型对应的 tokenizer 来测。2.2 结构约束比事后修复更可靠另一个关键决策是在生成阶段就约束结构而不是等截断后再去修复。这两条路线的成本差异巨大。事后修复的思路是模型自由生成截断后拿到残缺文本用各种启发式规则去补全——补引号、补括号、补逗号。这种做法在简单场景下能work但一旦嵌套层级深了或者字符串里包含特殊字符修复逻辑就会变得极其复杂且脆弱。你永远不知道截断点后面缺的是什么只能猜。生成时约束的思路是用 grammar-based decoding 或者 JSON schema 约束让模型在每一步只能输出符合结构规则的 token。这样即使被截断已生成的部分也天然是一个合法的前缀。比如 JSON 的语法要求对象必须以}结尾那在约束下模型不会在对象没闭合时就输出其他东西。截断发生时你拿到的是一个未闭合但结构正确的前缀补一个}就能解析。我实测下来约束解码的 overhead 在本地推理上完全可以接受。llama.cpp 的 GBNF grammar、Outlines 库、以及 vLLM 的 guided decoding都能做到只对输出 token 做掩码不影响推理速度。代价是前期要花时间把工具 schema 转成 grammar 或正则约束但这是一次性成本。2.3 解析器要能接受不完整但合法的输入第三个设计要点是解析器的容错能力。标准的 JSON 解析器要求输入完整少一个括号就报错。但在这个场景下我们需要一个宽容解析器它能接受一个未闭合的 JSON 前缀并返回已经解析出来的部分字段。举个例子工具调用是{name: search, arguments: {query: 本地大模型, limit: 10}}如果截断在{name: search, arguments: {query: 本地大模型宽容解析器应该能提取出name和arguments.query而limit字段缺失就用默认值。这样工具至少能带着部分参数执行而不是完全失败。这种解析器的实现方式有几种一是用流式 JSON 解析器边解析边产出已完成的键值对二是用栈式解析遇到未闭合的结构就自动补全三是用 LLM 本身做二次修复把残缺 JSON 喂给模型让它补全。第一种最快最可控第二种实现简单但边界情况多第三种最灵活但成本高。我一般推荐第一种为主、第二种兜底。3. 核心细节解析token 预算、结构约束与截断处理3.1 Token 预算的分层计算方法先讲预算怎么算。假设你的模型上下文窗口是 8192 token一次工具调用请求的预算分配大概是这样的预算项建议占比说明系统提示词10%包含角色设定、行为规范工具 schema15%所有可用工具的完整描述对话历史30%按时间倒序保留超出则裁剪用户输入10%当前轮次的用户消息工具调用输出25%模型生成工具调用的空间结果回填预留10%工具执行结果写回对话的空间这个比例不是固定的要根据实际场景调。如果你的工具特别多、schema 特别长工具 schema 那一项可能要占到 25%那就得从对话历史里省。如果用户输入经常很长用户输入那一项要往上调。关键点是工具调用输出这一项必须设一个下限不能因为其他项挤占就无限压缩。这个下限就是前面说的最小可解析窗口。我通常的做法是在请求发起前先算一遍可用输出预算 总窗口 - 系统提示词 - 工具schema - 对话历史 - 用户输入 - 结果回填预留如果这个值小于最小可解析窗口就触发裁剪逻辑——要么砍对话历史要么砍工具列表只保留最相关的几个工具要么直接告诉用户输入太长了。提示裁剪对话历史时不要简单地从最老的开始删。工具调用的上下文是有依赖的删掉中间某轮可能导致模型无法理解当前状态。我的做法是按对话轮次为单位裁剪保留最近 N 轮完整对话而不是按 token 数硬切。3.2 用 Grammar 约束工具调用的输出结构结构约束这块我用的是 GBNF grammarllama.cpp 的格式或者 JSON schema 转正则。核心思路是把工具调用的 JSON 结构定义成一个语法模型在生成时每一步的候选 token 都被语法规则过滤只能输出符合结构的 token。一个简单的工具调用 grammar 大概长这样root :: { ws \name\ ws : ws string ws , ws \arguments\ ws : ws object ws } object :: { ws (pair (ws , ws pair)*)? ws } pair :: string ws : ws value value :: string | number | object | array | true | false | null string :: \ ([^\\] | \\ .)* \ number :: -? [0-9] (. [0-9])? array :: [ ws (value (ws , ws value)*)? ws ] ws :: [ \t\n]*这个 grammar 保证了模型输出的永远是合法的 JSON 结构。当 token 预算耗尽时输出会被截断但截断点一定落在某个语法规则的中间而不是产生非法字符。比如可能截断在{name: search, arguments: {query: 本地这是一个合法的前缀只是字符串没闭合。实际使用中我会根据每个工具的 schema 动态生成 grammar。比如search工具有query和limit两个参数query是字符串limit是整数那 grammar 里arguments对象就约束成只允许这两个键。这样模型不会输出 schema 之外的字段也减少了无效 token 的消耗。3.3 截断点的边界处理策略即使有 grammar 约束截断仍然会发生。关键是截断后怎么处理。我的策略分三步第一步检测截断。在生成循环里每次采样后检查是否达到 max_tokens 或者遇到停止符。如果是因为 token 限制停止的标记为truncated状态而不是finished状态。第二步尝试补全。拿到截断的文本后用一个轻量的补全器尝试修复。补全器的逻辑是扫描文本维护一个栈遇到{或[入栈遇到}或]出栈。扫描结束后栈里剩下的就是未闭合的结构按后进先出的顺序补上对应的闭合符号。如果截断在字符串中间即最后一个引号是开引号先补一个引号再补结构闭合符。第三步宽容解析。补全后的文本喂给宽容解析器提取出所有已完成的字段。如果name字段完整就认为这是一个可执行的工具调用如果name都不完整那就只能放弃返回一个明确的工具调用不完整状态给上层。这里有个细节补全后的 JSON 可能语义上不完整。比如limit字段被截断了补全后可能变成limit:后面直接跟}这是非法的。所以补全器在补结构闭合符之前要先检查最后一个键值对是否完整。如果不完整就把这个键值对整个删掉再补闭合符。这个逻辑用正则或者简单的状态机就能实现。4. 实操过程从零搭一个抗截断的工具调用引擎4.1 环境准备与依赖选型我用的技术栈是 Python llama-cpp-python因为它在本地推理上最灵活支持 GBNF grammar而且能直接拿到 token 级别的输出。如果你用 Ollama它底层也是 llama.cpp但 grammar 的支持要通过 Modelfile 配置灵活性差一些。vLLM 适合服务化部署guided decoding 功能很强但本地单机跑有点重。依赖清单pip install llama-cpp-python pip install partial-json-parser pip install jsonschemapartial-json-parser是一个专门解析不完整 JSON 的库能处理截断在任意位置的 JSON 文本。jsonschema用来校验解析出来的工具调用是否符合工具定义。模型我选的是 Qwen2.5-7B-Instruct 的 GGUF 量化版Q4_K_M 量化在 16GB 显存的机器上跑得很稳。这个模型对工具调用的支持比较好输出格式比较规范。4.2 工具 Schema 到 Grammar 的转换先定义工具 schematools [ { name: search, description: 搜索本地文档库, parameters: { type: object, properties: { query: {type: string, description: 搜索关键词}, limit: {type: integer, description: 返回结果数量, default: 10} }, required: [query] } } ]然后写一个转换函数把 schema 转成 GBNF grammardef schema_to_gbnf(tools): tool_names | .join(f{t[name]} for t in tools) grammar f root :: {{ ws \\name\\ ws : ws name ws , ws \\arguments\\ ws : ws args ws }} name :: {tool_names} args :: {{ ws (pair (ws , ws pair)*)? ws }} pair :: string ws : ws value value :: string | number | true | false | null string :: \\ ([^\\\\] | \\\\ .)* \\ number :: -? [0-9] (. [0-9])? ws :: [ \\t\\n]* return grammar这个 grammar 比较宽松args里允许任意键值对。如果要严格约束每个工具的参数需要为每个工具单独生成args规则然后在name匹配后切换到对应的args。GBNF 支持这种上下文相关的规则但写起来比较复杂。我的经验是先用宽松 grammar 跑通流程再根据实际需要收紧。4.3 生成循环与截断检测核心的生成逻辑from llama_cpp import Llama from partial_json_parser import loads as partial_loads llm Llama(model_pathqwen2.5-7b-instruct-q4_k_m.gguf, n_ctx8192) def generate_tool_call(prompt, tools, max_tokens512): grammar schema_to_gbnf(tools) output llm( prompt, max_tokensmax_tokens, grammargrammar, stop[/tool_call], echoFalse ) text output[choices][0][text] finish_reason output[choices][0][finish_reason] truncated (finish_reason length) if truncated: text repair_truncated_json(text) try: parsed partial_loads(text) except Exception as e: return {status: parse_failed, raw: text, error: str(e)} if name not in parsed: return {status: incomplete, raw: text, parsed: parsed} return { status: ok, truncated: truncated, tool_call: parsed }repair_truncated_json是补全函数def repair_truncated_json(text): stack [] in_string False escape False for ch in text: if escape: escape False continue if ch \\: escape True continue if ch : in_string not in_string continue if in_string: continue if ch in {[: stack.append(ch) elif ch }: if stack and stack[-1] {: stack.pop() elif ch ]: if stack and stack[-1] [: stack.pop() if in_string: text # 删除最后一个不完整的键值对 text text.rstrip() if text.endswith(:) or text.endswith(,): text text.rstrip(:,) # 回退到上一个逗号或左括号 last_comma max(text.rfind(,), text.rfind({)) if last_comma 0: text text[:last_comma] for opener in reversed(stack): text } if opener { else ] return text这段代码的逻辑是先扫描文本记录未闭合的结构和字符串状态。如果截断在字符串里补一个引号。然后检查末尾是否有不完整的键值对有就删掉。最后按栈的顺序补上闭合符。4.4 宽容解析与字段提取补全后的文本用partial_loads解析。这个库的特点是即使 JSON 不完整它也能返回已经解析出来的部分。比如输入{name: search, arguments: {query: 本地它会返回{name: search, arguments: {query: 本地}}把未闭合的部分自动补上。解析出来后用jsonschema校验from jsonschema import validate, ValidationError def validate_tool_call(tool_call, tools): tool_def next((t for t in tools if t[name] tool_call.get(name)), None) if not tool_def: return False, unknown tool try: validate(instancetool_call.get(arguments, {}), schematool_def[parameters]) return True, None except ValidationError as e: return False, str(e)如果校验失败比如query字段缺失因为被截断删掉了那就返回一个明确的错误让上层决定是重试还是用默认值。我的做法是对于有默认值的字段用默认值填充对于必填字段缺失的返回incomplete状态让上层决定是否用更小的 max_tokens 重试一次。5. 常见问题与排查技巧实录5.1 截断位置总在同一个地方怎么办这是最常见的问题。如果你发现每次截断都发生在工具调用的同一个位置说明你的 max_tokens 设置得刚好卡在那个位置。解决办法不是简单调大 max_tokens而是先分析为什么输出会那么长。可能的原因有几个一是工具 schema 太复杂模型在arguments里输出了很多不必要的字段二是模型在工具调用前输出了大段的思考过程把预算耗光了三是对话历史太长挤占了输出空间。排查方法把模型的完整输出打印出来看看截断点前面是什么。如果是思考过程太长就在系统提示词里明确要求直接输出工具调用不要解释如果是 schema 太复杂就精简 schema去掉不必要的描述字段如果是对话历史太长就加裁剪逻辑。提示llama.cpp 的n_ctx参数决定了总上下文窗口max_tokens决定了单次生成的上限。这两个要配合调。如果n_ctx是 8192max_tokens设 4096那留给 prompt 的空间就只有 4096很容易触发 prompt 截断。5.2 Grammar 约束导致输出变慢或卡死Grammar 约束在理论上只影响采样时的 token 掩码不应该显著影响速度。但实际使用中如果 grammar 写得不好比如有大量的可选分支或者递归嵌套会导致每次采样的候选 token 计算变慢。我遇到过一次grammar 里用了递归的value :: object | array | string | number而object和array又包含value导致解析器在每一步都要做深度递归检查生成速度从每秒 30 token 掉到每秒 5 token。解决办法是限制递归深度或者把递归结构展开成有限层级。另一个坑是 grammar 和模型的 tokenizer 不匹配。有些 tokenizer 会把{当成一个 token而 grammar 期望的是{和两个 token这会导致 grammar 永远无法匹配模型输出为空。解决办法是用llama_cpp的grammar参数时确保 grammar 的终结符和 tokenizer 的 token 对齐。实测下来Qwen 系列的 tokenizer 对 JSON 结构的 token 化比较友好Llama 系列有时候会把:合并成一个 token需要特别注意。5.3 补全后的 JSON 语义错误补全器只能保证语法正确不能保证语义正确。我遇到过一种情况截断发生在数字中间比如limit: 10被截成limit: 1补全后变成limit: 1语法合法但语义错了——用户想要 10 条结果实际只返回 1 条。这种问题很难完全避免因为截断点是不可控的。我的缓解策略是对于数值型参数如果截断发生在数字中间就把这个字段整个删掉用默认值代替。判断截断发生在数字中间的方法是检查补全前的文本如果最后一个字符是数字且这个数字后面没有分隔符逗号、右括号等就认为可能被截断。另一个策略是在工具定义里给所有数值参数设默认值这样即使字段被删掉工具也能用默认值执行。对于必填的数值参数如果被截断就返回incomplete状态让上层决定是否重试。5.4 常见问题速查表问题现象可能原因排查方法解决方案截断位置固定max_tokens 卡在关键位置打印完整输出看截断点调大 max_tokens 或精简 schema输出为空grammar 与 tokenizer 不匹配检查 grammar 终结符调整 grammar 或换 tokenizer生成速度骤降grammar 递归过深分析 grammar 复杂度限制递归深度或展开结构补全后语义错误截断在数值中间检查截断点前一个字符删除该字段用默认值解析器报错补全逻辑有 bug用边界用例测试修复补全器的字符串处理工具调用不执行name 字段缺失检查 parsed 结果返回 incomplete 让上层重试5.5 几个我踩过的坑第一个坑是转义字符处理。JSON 字符串里的\和\\会让简单的字符扫描逻辑出错。我最初的补全器没有处理转义遇到query: C:\\path\\to\\file这种输入会把\\后面的引号当成字符串结束导致补全位置错误。后来加了escape状态才解决。第二个坑是嵌套数组的补全。工具参数里如果有数组比如tags: [a, b补全时不仅要补]还要注意数组元素之间的逗号。我的做法是如果截断在数组元素中间先把最后一个不完整的元素删掉再补]。第三个坑是多工具调用的场景。有些模型会一次输出多个工具调用比如[{name: search, ...}, {name: read, ...}]。如果截断在第二个工具调用中间补全逻辑要能识别出这是一个数组并且只补全第二个元素而不是把整个数组闭合。这个场景下宽容解析器要能返回第一个完整的工具调用忽略第二个不完整的。第四个坑是stop token 和截断的混淆。llama.cpp 的finish_reason有stop和length两种。stop表示遇到了停止符输出是完整的length表示达到了 max_tokens输出可能被截断。但有时候模型输出了停止符但 JSON 本身就不完整模型自己写错了这时候finish_reason是stop但解析仍然会失败。所以不能只靠finish_reason判断是否需要补全还要在解析失败时也尝试补全。6. 性能优化与扩展思路6.1 减少无效 token 消耗工具调用场景下很多 token 是浪费的。比如模型经常输出arguments: {}这种空对象或者输出 schema 里没有定义的字段。用 grammar 约束可以消除这部分浪费但 grammar 本身也有开销。我的优化做法是在系统提示词里明确告诉模型只输出工具调用不要输出其他内容并且在 grammar 里把arguments的字段约束到最小集合。实测下来一个原本需要 200 token 的工具调用优化后可以压到 80 token 左右截断的概率大幅降低。另一个优化是动态调整 max_tokens。不要每次都用固定的 max_tokens而是根据当前请求的复杂度动态计算。简单的单参数工具max_tokens 设 128 就够复杂的多参数工具设 512。这样可以在简单请求上节省预算留给复杂请求更多空间。6.2 流式解析与提前执行如果工具调用的参数是逐步生成的可以考虑流式解析每生成一个完整的键值对就尝试执行部分逻辑。比如search工具的query字段先生成出来就可以先发起搜索等limit字段生成后再过滤结果。这样即使后面被截断前面的工作也没白费。实现方式是在生成循环里每输出一个 token 就喂给流式 JSON 解析器解析器每完成一个键值对就回调一次。回调里检查是否满足工具执行的最小条件满足就触发执行。这种做法的复杂度较高但在长工具调用场景下收益明显。6.3 多轮重试的预算控制如果第一次工具调用被截断且无法补全需要重试。重试时不能简单地把 max_tokens 调大因为总上下文窗口是有限的。我的做法是重试时先裁剪对话历史把最老的几轮对话删掉腾出预算给工具调用输出。如果裁剪后仍然不够就换一个更小的模型或者更精简的 schema。重试次数我一般限制在 2 次以内。第一次用原始预算第二次裁剪历史后重试第三次如果还失败就返回明确的错误给用户让用户简化输入或者换一种表达方式。无限重试在本地推理上是不可接受的因为每次重试都是实打实的算力消耗。6.4 扩展到多工具并行调用当有多个工具可以并行调用时截断问题会更复杂。比如模型要同时调用search和read输出是一个数组截断可能发生在数组中间。我的处理方式是把数组里的每个工具调用当成独立的单元补全时只补全最后一个不完整的单元前面的完整单元正常解析。如果最后一个单元连name都没生成出来就丢弃它只执行前面完整的工具调用。这种部分成功的策略比全部失败要好得多。用户至少能得到部分结果而不是一个完整的错误。当然前提是工具之间没有强依赖关系。如果有依赖比如read需要search的结果作为输入那就只能等search完成后再调用read不能并行。7. 写在最后的一些个人体会这套方案我在三个项目里落地过最大的感受是抗截断不是某一个环节的事而是从预算分配、结构约束、生成控制到解析修复的整条链路。任何一个环节偷懒都会在截断发生时暴露出来。我见过有人只加了一个 try-except 就号称解决了截断问题结果遇到嵌套 JSON 就崩。也见过有人把 max_tokens 调到最大以为这样就不会截断结果 prompt 太长导致模型根本没空间输出。这些坑我都踩过所以现在做本地推理第一件事就是把 token 预算算清楚第二件事就是把 grammar 约束加上第三件事才是写业务逻辑。如果你刚开始做本地大模型的工具调用我的建议是先用最简单的单工具、单参数场景跑通全流程确认截断补全逻辑没问题再逐步增加工具数量和参数复杂度。不要一上来就搞多工具并行加嵌套参数那样出了问题很难定位是哪个环节的锅。另外宽容解析器这块partial-json-parser这个库虽然好用但它对某些边界情况的处理不够完美比如截断在 Unicode 转义序列中间\u00这种。如果你的场景里经常出现非 ASCII 字符建议自己写一个针对性的解析器或者在这个库的基础上打补丁。我自己是 fork 了一份加了 Unicode 转义的处理逻辑目前跑了几十万次调用没再出过解析失败的问题。
返回列表