NanoGPT实现原生函数调用:从零构建结构化输出能力

发布时间:2026/7/2 16:26:14
NanoGPT实现原生函数调用:从零构建结构化输出能力 1. 项目概述从零开始用 NanoGPT 实现函数调用能力不是调 API是让模型真正“理解”要调什么你有没有试过让一个语言模型直接生成符合 OpenAI Function Calling 格式的 JSON不是靠 prompt 工程硬凑也不是靠后处理规则强行改写而是让模型在推理时原生输出结构化、可解析、带参数校验的 function_call 字段——就像它自己真的“想清楚了该调哪个函数、传哪些参数”一样。这个项目标题里的 “From First Principles” 不是修辞是实打实的操作路径我们不碰任何大模型服务接口不依赖 vLLM 或 Ollama 的插件机制就用 Andrej Karpathy 那个只有 1200 行 Python 的 NanoGPT 代码库从 tokenizer 构建、数据格式设计、损失函数改造到训练策略微调全程手拆手搭把 function calling 这个能力像搭积木一样焊进模型的底层行为逻辑里。核心关键词就是NanoGPT、function calling、fine-tuning、structured output、first principles。它解决的不是“怎么调外部工具”而是“怎么让小模型具备生成可靠结构化动作指令”的根本能力。适合三类人想吃透 LLM 输出控制原理的算法工程师、需要轻量级本地函数调用能力的产品原型开发者、以及被大模型幻觉折磨够了决心从最简模型开始重建可控性的技术决策者。这不是一个“加个插件就能用”的方案而是一次对生成式 AI 底层契约的重新定义——我们不再把结构化输出当作 prompt 的副产品而是把它变成模型训练目标本身。2. 整体设计思路为什么非得动 NanoGPT 的底层而不是套个 wrapper2.1 函数调用的本质是约束下的序列生成问题很多人一听到 function calling第一反应是“调 OpenAI 的 API”第二反应是“用 LangChain 做 tool calling”。但这两个方案都绕开了一个关键事实真正的函数调用能力必须内化为模型对 token 序列的条件概率建模能力。OpenAI 的 function calling 接口背后是他们在预训练后训练阶段用大量人工标注的function namexxxparameters{a:1}/parameters/function格式数据强制模型学习“当用户问‘查北京天气’时下一个最可能的 token 是function而不是今天”。这本质上是一个强结构化 token 预测任务和普通文本续写有本质区别普通续写允许语义模糊、风格多变而 function calling 要求模型在特定位置必须输出完全合法的 JSON key、引号、括号嵌套且参数值必须符合 schema 定义的类型与范围。如果你只是在 NanoGPT 推理时加个 post-process 正则替换比如把weather替成{name:get_weather,parameters:{city:Beijing}}那模型根本没学会“什么时候该触发函数”它只是在胡说八道之后被你用规则强行“翻译”成结构。这会导致两个致命问题一是泛化差换个函数名或参数字段就崩二是不可控模型在生成name: get_wea时你根本没法判断它是打字错误还是真想调这个函数。2.2 NanoGPT 是唯一能让你看清“契约如何建立”的显微镜为什么选 NanoGPT而不是 HuggingFace 上随便一个 7B 的 Llama 模型因为它的代码足够透明足够“脏”。Karpathy 的实现里没有抽象层没有 config.yaml没有 trainer loop 封装。model.py里forward()函数的每一行你都能对应到矩阵乘法和 softmax 的数学含义train.py里loss F.cross_entropy(logits.view(-1, logits.size(-1)), targets.view(-1))这一行就是整个训练目标的全部——它要求模型对每个位置的下一个 token给出最准确的概率分布。而 function calling 的改造就发生在这行 loss 计算之前。我们要做的不是加个新模块而是重定义 targets目标 token 序列的构成方式以及 logits模型输出的解码逻辑。比如当用户输入帮我订明天下午三点的会议室标准 NanoGPT 的 targets 是一串普通文本 token而我们的 targets 必须是[|startfunc|, get_meeting_room, |paramstart|, time, :, \2024-06-15T15:00:00\, ,, room_id, :, 101, |paramend|, |endfunc|]。这个序列里混着 special token、函数名、JSON key、字符串值、标点符号——它们全都要被 tokenizer 编码成整数 ID然后喂给模型去预测。NanoGPT 的极简性让你能亲手把get_meeting_room这个字符串塞进 vocab.txt能亲眼看到 embedding layer 如何把它映射成向量能调试出为什么模型总在:后面多预测一个空格导致 JSON 解析失败。这种“所见即所得”的控制感在任何封装好的大模型框架里都是奢望。2.3 改造路径三步走每一步都直击生成控制的核心痛点整个 fine-tuning 流程被拆解为三个不可跳过的硬核环节它们共同构成了 function calling 的“技术契约”Schema-Aware Tokenization模式感知分词不是简单地把函数定义 dump 成文本喂给 tokenizer。我们要把每个函数的 name、description、parameters schema包括 required 字段、type、enum 约束编译成一套可学习的 special token 序列。例如get_weather函数的 schema 会被编码为|funcdef|get_weather|desc|Get current weather|param|city|type|string|req|True|param|unit|type|string|enum|celsius,fahrenheit|enddef|。这个序列会作为 context prefix 加在用户 query 前面让模型明确知道“接下来你要生成的是符合这个 schema 的调用”。这步解决了“模型不知道有哪些函数可用”的问题。Structured Output Loss结构化输出损失标准 cross-entropy loss 对所有 token 一视同仁。但在 function calling 中|startfunc|和}的错误代价天壤之别。我们引入token-level weight mask对|startfunc|、|paramstart|、|paramend|、|endfunc|这些 structural tokenloss weight 设为 5.0对函数名和参数 keyweight 设为 2.0对字符串值中的普通字符weight 保持 1.0。这样模型会优先学好“何时开始/结束函数块”再学“调哪个函数”最后才抠“参数值细节”。这步解决了“模型乱序生成先输出参数再输出函数名”的幻觉问题。Constrained Decoding at Inference推理时约束解码训练好了不代表推理就稳。我们实现了一个轻量级的grammar-guided sampling模块它在每个 decoding step 动态构建 allowed_tokens 集合。例如当模型刚输出|startfunc|allowed_tokens 就只包含所有已注册的函数名当它输出了get_weatherallowed_tokens 就立刻切换为[|paramstart|, |endfunc|]当它进入参数值字符串allowed_tokens 就限制为 ASCII 字母、数字、下划线和预设的 enum 值。这个模块不改变模型权重只做实时 token 过滤却能把 JSON 语法错误率从 37% 降到 1.2%。这步解决了“训练好了但推理时还是生成非法 JSON”的最后一公里问题。这三步环环相扣没有 schema-aware tokenization模型就不知道约束在哪没有 structured loss模型就学不会分清主次没有 constrained decoding再好的训练也白搭。它们共同回答了那个根本问题如何让一个纯自回归模型产生确定性的、可验证的、符合外部系统契约的输出3. 核心细节解析从 tokenizer 改造到损失函数重写手把手拆解每个关键环节3.1 Schema-Aware Tokenization把函数定义“编译”成模型能懂的语言标准的 GPT tokenizer如 tiktoken是为通用文本设计的它会把{name:get_weather}拆成{,name,:,,get_weather,这一堆碎片。这对 function calling 是灾难——模型要同时学 JSON 语法、引号配对、冒号位置还要学函数语义负担太重。我们的方案是为函数调用专门设计一套 semantic token vocabulary并在数据预处理阶段把函数 schema “编译”成固定 token 序列。第一步扩展 vocab。我们在原始 NanoGPT 的encode/decode函数基础上新增一组 special tokensSPECIAL_TOKENS { |startfunc|: 50256, |endfunc|: 50257, |paramstart|: 50258, |paramend|: 50259, |funcdef|: 50260, |desc|: 50261, |param|: 50262, |type|: 50263, |req|: 50264, |enum|: 50265, |enddef|: 50266, }这些 token ID 被硬编码进 tokenizer 的merges.txt和vocab.json确保它们在所有上下文中都有唯一、稳定的 ID。注意我们没有用tokenizer.add_tokens()这种动态方式因为 NanoGPT 的 tokenizer 是静态加载的动态添加会导致训练/推理 vocab size 不一致。第二步schema 编译器。我们写了一个SchemaCompiler类它接收一个 OpenAPI 3.0 风格的函数定义 dict输出一个 token ID listdef compile_schema(self, func_def): tokens [self.token_to_id[|funcdef|]] tokens self._encode_string(func_def[name]) tokens [self.token_to_id[|desc|]] tokens self._encode_string(func_def[description]) for param_name, param_spec in func_def[parameters][properties].items(): tokens [self.token_to_id[|param|]] tokens self._encode_string(param_name) tokens [self.token_to_id[|type|]] tokens self._encode_string(param_spec[type]) if param_name in func_def[parameters].get(required, []): tokens [self.token_to_id[|req|], self.token_to_id[True]] if enum in param_spec: tokens [self.token_to_id[|enum|]] tokens self._encode_string(,.join(param_spec[enum])) tokens [self.token_to_id[|enddef|]] return tokens这里的关键技巧是_encode_string()它不是直接调用tokenizer.encode()而是对字符串做预处理——移除所有空格和换行把替换成|quote|一个额外的 special token把:替换成|colon|。这样city:Beijing就被编译成[|param|, city, |colon|, |quote|, Beijing, |quote|]彻底规避了原始 tokenizer 对标点符号的随意切分。实测下来经过编译的 schema token 序列长度稳定在 80-120 tokens且 100% 可逆 decode为后续的 loss 计算和推理约束提供了坚实基础。3.2 Structured Output Loss给不同 token “定价”让模型学会抓重点标准的F.cross_entropy对每个位置的预测错误施加同等惩罚。但在 function calling 中错一个}可能导致整个 JSON 解析失败而错一个参数值里的字母可能只是影响精度。我们必须让 loss 函数“懂得轻重缓急”。我们的解决方案是在计算 loss 前动态生成一个 weight mask根据当前 token 的语义角色赋予不同权重。具体实现是在train.py的 training loop 里# targets 是 shape (B, T) 的 token ID tensor # logits 是 shape (B, T, V) 的未归一化输出 loss F.cross_entropy( logits.view(-1, logits.size(-1)), targets.view(-1), reductionnone # 关键不自动求均值 ) # 生成 weight maskshape 同 loss weight_mask torch.ones_like(loss) for i, target_id in enumerate(targets.view(-1)): if target_id in STRUCTURAL_TOKEN_IDS: # [|startfunc|, |endfunc|, ...] weight_mask[i] 5.0 elif target_id in FUNCTION_NAME_IDS or target_id in PARAM_KEY_IDS: weight_mask[i] 2.0 # 其余默认为 1.0 weighted_loss (loss * weight_mask).mean() # 最终 lossSTRUCTURAL_TOKEN_IDS是我们预先定义的 structural token ID 列表FUNCTION_NAME_IDS是所有函数名 token 的 ID 集合通过tokenizer.encode(func_name)获取并缓存。这个 weight mask 的设计源于一个深刻的观察在训练初期模型在|startfunc|后总是胡乱输出因为它根本没建立起“函数调用块”的概念。把startfunc的 loss weight 设为 5.0相当于告诉模型“你要是连什么时候该开始调函数都搞不清其他都白学”。我们做了 A/B 测试不加 weight mask 的 baseline 模型在 epoch 20 时|startfunc|的预测准确率只有 63%而加了 mask 的模型在 epoch 12 就达到了 92%。更妙的是这种权重不是拍脑袋定的。我们用梯度分析发现|startfunc|位置的梯度 norm 比普通 token 高出 4.8 倍这说明模型天然就认为这个位置更重要——我们的 weight mask只是把模型内在的“注意力分配”显式地编码进了 loss 函数里。3.3 Constrained Decoding推理时的“交通协管员”实时拦截非法 token训练再好如果推理时模型可以自由选择任意 token那{name:get_wea这种半截子 JSON 就永远无法避免。标准的 top-k 或 temperature sampling 对此无能为力。我们的方案是在每个 decoding step根据已生成的 token 序列动态构建一个 allowed_tokens 集合强制模型只能从这个集合里选。这本质上是一个轻量级的、基于 grammar 的有限状态机FSM。我们实现了一个GrammarConstrainer类其核心是get_allowed_tokens()方法def get_allowed_tokens(self, generated_ids): # generated_ids 是已生成的 token ID list state self._infer_state(generated_ids) # 根据历史推断当前状态 if state WAITING_FOR_FUNC: # 等待函数名只允许所有已注册函数名的 token ID return self.func_name_ids elif state IN_PARAM_BLOCK: # 在参数块内只允许 |paramstart|, |paramend|, 参数 key token return self.param_start_end_ids self.param_key_ids elif state IN_STRING_VALUE: # 在字符串值内只允许 ASCII 字母、数字、下划线、预设 enum 值 return self.ascii_alnum_ids self.enum_value_ids else: return list(range(self.vocab_size)) # 默认全放开_infer_state()是状态机的核心它通过 pattern matching 分析generated_ids的后缀。例如如果后缀是[|startfunc|]state 就是WAITING_FOR_FUNC如果后缀是[|startfunc|, 12345, |paramstart|]12345 是 get_weather 的 IDstate 就是IN_PARAM_BLOCK。这个状态机只有 7 个状态代码不到 200 行但它把 JSON 语法、schema 约束、函数调用协议全部编码了进去。实测效果惊人在 1000 个测试样本上未加约束的模型 JSON 解析失败率是 37.2%加上这个 constrainer 后失败率降至 1.2%且所有失败案例都是因模型在字符串值里生成了未授权的 emoji我们没把 emoji 加入 allowed set而非语法错误。这证明结构化生成的可靠性不在于模型有多大而在于你能否在推理时用最小的开销把它关进正确的“语法笼子”里。4. 实操过程从环境准备到完整训练记录每一个踩坑的瞬间与修复方案4.1 环境准备与依赖定制为什么必须用 PyTorch 2.0 和 CUDA 11.8NanoGPT 的原始代码对 PyTorch 版本极其敏感。我们实测了多个组合最终锁定PyTorch 2.1.0 CUDA 11.8 Python 3.10为黄金配置。原因有三Flash Attention 2 的兼容性NanoGPT 的mha.py使用了flash_attn库。PyTorch 2.0 的torch.compile()会与 flash attention 的 kernel 冲突导致训练时出现CUDA error: device-side assert triggered。PyTorch 2.0 重构了编译器后端完美兼容。我们用pip install flash-attn --no-build-isolation安装避开了常见的 build 错误。torch.nn.functional.scaled_dot_product_attention的稳定性这是 PyTorch 2.0 引入的原生 SDPA。在 NanoGPT 的Block类中我们把原来的MultiHeadAttention替换为这个原生实现并设置enable_mathFalse, enable_flashTrue, enable_mem_efficientTrue。实测下来它比 hand-written attention 快 1.8 倍且内存占用降低 35%这对在单张 24GB 3090 上跑 full fine-tuning 至关重要。CUDA 11.8 的驱动匹配很多用户卡在nvidia-smi显示驱动版本是 525但nvcc --version报错。这是因为 Ubuntu 22.04 默认的nvidia-cuda-toolkit包是旧版。正确做法是先sudo apt remove nvidia-cuda-toolkit然后从 NVIDIA 官网下载cuda_11.8.0_520.61.05_linux.run运行时取消勾选 driver installation只装 toolkit最后export PATH/usr/local/cuda-11.8/bin:$PATH。这一步我们踩了 3 天坑重装了 7 次系统才确认是 toolkit 版本不匹配导致的 silent crash。环境脚本如下可直接复制执行# 创建干净 conda env conda create -n nanogpt-fc python3.10 conda activate nanogpt-fc # 安装 PyTorch 2.1.0 with CUDA 11.8 pip3 install torch2.1.0cu118 torchvision0.16.0cu118 torchaudio2.1.0cu118 --extra-index-url https://download.pytorch.org/whl/cu118 # 安装 Flash Attention 2 pip install flash-attn --no-build-isolation # 克隆并修改 NanoGPT git clone https://github.com/karpathy/nanoGPT.git cd nanoGPT # 应用我们的 patch见下文 git apply ../nanogpt-fc-patch.diff4.2 数据集构建不是丢一堆 JSONL 进去而是精心设计“教学序列”很多人以为 function calling 数据集就是收集一堆user: 查天气, function_call: {...}的样本。这是大错特错。NanoGPT 是 causal LM它学的是“给定前面所有 token预测下一个 token”。所以数据集的核心是构造出能让模型清晰理解“输入-输出契约”的 token 序列。我们设计了三种数据格式按 4:3:3 比例混合Schema-Prefixed FormatSchema 前缀格式40%这是主力训练数据。每个样本形如|funcdef|get_weather|desc|Get current weather|param|city|type|string|req|True|param|unit|type|string|enum|celsius,fahrenheit|enddef| |user|北京现在多少度|assistant||startfunc|get_weather|paramstart|city:Beijing,unit:celsius|paramend||endfunc|关键点schema 部分是固定的、编译好的 token ID 序列user query 和 assistant response 是原始文本经 tokenizer encode。这种格式强制模型把 schema 当作 context学习“在这个 schema 下用户问什么我该输出什么”。Function-Only Format纯函数格式30%用于强化函数名和参数结构的记忆。样本形如|startfunc|get_weather|paramstart|city:Shanghai|paramend||endfunc| |startfunc|get_weather|paramstart|city:Guangzhou,unit:fahrenheit|paramend||endfunc|这些是纯函数调用序列没有 user query。它们被用来做“函数调用语法”的专项训练提升模型对|paramstart|和|paramend|的敏感度。Negative Sampling Format负采样格式30%专门用来打击幻觉。我们人工构造了 500 个“看起来像函数调用但其实是错的”样本例如|user|帮我订会议室|assistant||startfunc|get_weather|paramstart|city:Beijing|paramend||endfunc|这里模型应该输出book_meeting_room但我们故意给它一个get_weather的错误 response。在训练时我们对这类样本的 loss weight 设为 3.0让模型深刻记住“订会议室 ≠ 查天气”。数据集总计 12,000 个样本全部手工清洗。我们用data/preprocess.py脚本统一编译、分词、保存为 mmap 格式。一个关键经验不要用json.loads()直接解析原始 JSONL因为里面可能有非法转义符。必须先用正则re.sub(r\\([^u]|$), r\\\\\1, line)预处理。这个 bug 让我们浪费了 18 小时直到发现模型总在后面崩溃。4.3 训练配置与超参调优为什么 batch_size12、lr3e-4 是最优解NanoGPT 的训练配置文件config/train_gpt2.py需要大幅修改。我们最终采用的配置如下关键参数# model n_layer 12 # 保持原样但 dropout0.0结构化任务怕过拟合 n_head 12 n_embd 768 # data block_size 256 # 输入长度上限够用。太长会 OOM batch_size 12 # 在 3090 上的极限再大就 CUDA out of memory # optimizer learning_rate 3e-4 # 经过网格搜索2e-4 学得太慢4e-4 开始震荡 max_iters 5000 # 约 3 个 epoch weight_decay 0.1 # 比原版 0.01 高防 overfitting # system device cuda dtype bfloat16 # 必须用 bfloat16float16 在 small model 上不稳定 compile True # 启用 torch.compile提速 1.4xbatch_size12的确定过程充满血泪。我们从 4 开始试bs4时GPU memory usage 18GB但 throughput 只有 8 samples/secbs8时memory 21GBthroughput 14bs12时memory 23.8GB逼近 24GB 上限throughput 21bs16时直接 OOM。我们画了 memory vs throughput 曲线发现bs12是拐点——再增加 batchmemory 线性涨throughput 却几乎不涨。这就是硬件瓶颈的物理定律。lr3e-4来自 learning rate finder。我们写了utils/lr_finder.py在前 100 个 iter 里lr 从 1e-5 指数增长到 1e-3记录 loss。曲线显示loss 在 lr2.5e-4 时开始明显下降在 3e-4 时下降最快到 3.5e-4 时 loss 开始抖动。我们取中间值 3e-4。一个反直觉的发现在 function calling 任务上warmup_steps0 比 warmup_steps100 效果更好。因为模型需要快速建立对 structural token 的敏感度缓慢 warmup 会让它在初期“摸不清重点”。我们把warmup_iters设为 0直接上 full lr。训练日志显示loss 从初始的 5.22随机猜测水平下降到 epoch 1 结束时的 2.18epoch 2 结束时的 1.45最终收敛在 1.23。最关键的指标是startfunc_acc|startfunc|位置的 top-1 准确率它从 41%epoch 0飙升到 94%epoch 2证明结构化意识已牢固建立。4.4 模型导出与推理部署如何把.pth文件变成可调用的 API训练完的ckpt.pt是一个 PyTorch checkpoint不能直接用。我们需要把它转换成一个轻量级、无依赖的推理引擎。我们的方案是用 TorchScript 导出一个generate_function_call()函数然后用 C 加载暴露为一个简单的 HTTP endpoint。第一步导出 TorchScript# model/export.py model GPT(GPTConfig()) # 加载 config model.load_state_dict(torch.load(ckpt.pt)[model]) model.eval() # 构造一个 dummy input dummy_input torch.randint(0, 50267, (1, 128), dtypetorch.long) traced_model torch.jit.trace(model, dummy_input) # 导出为 .pt traced_model.save(nanogpt-fc.ts)注意dummy_input的 shape 必须和训练时的block_size一致256否则 trace 会失败。我们花了 2 小时 debugRuntimeError: Expected all tensors to be on the same device最后发现是dummy_input在 CPU而 model 在 CUDA必须加.to(cuda)。第二步C 加载与 API 封装。我们用 libtorch 编写了一个极简 server// server/main.cpp #include torch/script.h #include httplib.h auto module torch::jit::load(nanogpt-fc.ts); httplib::Server svr; svr.Post(/call, [](const httplib::Request req, httplib::Response res) { auto input_json json::parse(req.body); std::string user_query input_json[query]; std::string schema_str input_json[schema]; // 编译好的 schema token IDs // 1. encode user_query and schema_str to tensor // 2. run model.forward() // 3. decode output to function_call JSON // 4. return as JSON response res.set_content(output_json.dump(), application/json); }); svr.listen(localhost, 8080);编译命令g -stdc14 -I/opt/libtorch/include -L/opt/libtorch/lib main.cpp -ltorch -lc10 -ltorch_cpu -o nanogpt-fc-server。整个 binary 只有 12MB不依赖 Python启动时间 100ms。我们用ab -n 1000 -c 10 http://localhost:8080/call压测QPS 稳定在 87P99 延迟 320ms。这意味着你可以把它部署在树莓派 4B8GB RAM上作为一个本地 function calling 微服务。这才是 NanoGPT 的终极价值它不是一个玩具而是一个可裁剪、可嵌入、可量产的生成式 AI 基石。5. 常见问题与排查技巧实录那些文档里绝不会写的、血泪换来的经验5.1 问题速查表高频故障现象、根因与一键修复现象根因修复方案验证方法训练 loss 不下降卡在 ~5.2targets里混入了-1padding tokencross_entropy把它当有效 label在data.py的__getitem__里targets torch.cat([x[1:], torch.tensor([-1])])改为targets torch.cat([x[1:], torch.tensor([0])])并确保ignore_index0传给F.cross_entropy打印targets[:10]确认没有-1推理时startfunc总是预测错准确率 50%weight_mask没生效reductionnone被漏掉检查train.py中F.cross_entropy调用确认有reductionnone和weight_mask乘法在 loss 计算后加print(weight_mask.sum().item())应为 batch_size * seq_lenJSON 解析失败报Expecting property name enclosed in double quotes模型在字符串值里生成了 unescaped如city:Beijing在GrammarConstrainer.get_allowed_tokens()的IN_STRING_VALUE状态把的 ID 从 allowed set 中移除用tokenizer.decode()查看 raw output定位非法torch.compile()报torch._dynamo.exc.BackendCompilerFailedPyTorch 版本与 CUDA toolkit 不匹配或flash_attn未正确安装降级到 PyTorch 2.1.0 CUDA 11.8重装flash-attn运行python -c import flash_attn; print(flash_attn.__version__)**startfunc生成了但后面紧跟着EOS函数体为空**constrained decoding的allowed_tokens在WAITING_FOR_FUNC状态漏掉了函数名 token ID5.2 实操心得那些让项目从“能跑”到“稳产”的关键细节Tokenizer 的encode必须是 deterministic 的NanoGPT 的原始encode函数在处理空格和标点时有随机性。我们重写了它强制add_bosFalse, add_eosFalse并用re.sub(r\s, , text).strip()预处理所有输入文本。这个改动让训练 loss 曲线从毛刺状变得平滑收敛速度提升 40%。教训在结构化任务中任何输入的不确定性都会被 loss 函数放大为训练的不稳定性。|paramend|和|endfunc|的顺序不能颠倒早期我们设计为|paramend||endfunc|但模型总在|paramend|后生成垃圾 token。后来我们改成|endfunc||paramend|endfunc 在前问题消失。分析发现模型把|paramend|当作“参数块结束”但没意识到“整个函数调用也结束了”所以继续胡说。把|endfunc|放前面相当于给模型一个更强的“终止信号”。这印证了一个原则structural token 的语义强度由它在序列中的位置和相邻 token 共同决定不是孤立存在的。不要迷信top_p0.9在 function calling 推理中top_p会破坏 constrained decoding 的确定性。我们一律用top_k1greedy或top_k5beam search。实测top_k5 beam size3 的组合在保持 99.1% JSON 合法