先说背景,不然后面容易看懵:我这边是两台机器分工,一台用 llama-server 把 Ternary-Bonsai-2-27B 跑成本地 OpenAI 兼容端点,另一台用 DeepSeek Harness(下面简称 DSH,一个支持自接 provider 的模型客户端)连上去用。一个是服务端,一个是客户端,记住这个分工就行,因为这次的坑恰好就卡在两者中间。
llama-server 的启动脚本我写得挺”满”:
1 | llama-server.exe ^ |
结果在 DSH 里聊着聊着,突然冒出一句:
已达到输出 token 上限,回答被截断,已有输出保留在对话中。发送”继续”可让模型接着输出。
第一反应是:是不是 -c 没生效?是不是 KV cache 爆了?是不是模型跑不动了?
排查一圈下来,结论很干脆:问题不在 llama-server,而在 DSH 客户端侧的 maxTokens 被用完了。
为什么先排除 llama-server?
llama-server 控制输出长度主要看 -n / --n-predict。我没加这个参数,默认是 -1,意思是:不主动限制输出,直到遇到 EOS 或者上下文窗口用满。
我这边 -c 196608 已经开到了 196k 上下文,如果真的撞上下文上限,llama-server 通常会报 context full、OOM,或者日志里能看到生成被窗口限制。但它没有。
真正说明问题的是 DSH 那句提示。这里要先补一个小知识:OpenAI 兼容接口每次返回都会带一个 finish_reason,常见的就两种——stop 表示模型自己说完了,length 表示被输出上限掐断了。DSH 那句”发送继续可接着输出”,正是它收到 finish_reason: "length" 之后的标准兜底动作。
也就是说,服务端没跑不动,是请求里带的输出上限先到了。
真正的坑:contextWindow 和 maxTokens 没对齐
我原本在 DSH 里配的本地模型是这样的:
1 | - id: llm-pi-ai |
问题就出在这里。
| 配置项 | 原本值 | 实际影响 |
|---|---|---|
contextWindow |
32768 | DSH 以为窗口只有 32k |
maxTokens |
16384 | 单次输出最多 16384 token |
llama-server -c |
196608 | 服务端其实能跑更长 |
也就是说,服务端给了 196k 的跑道,但 DSH 只被允许跑 16k 输出。
为什么 contextWindow 报小了也会跟着出事?因为客户端不是只拿 maxTokens 一个数去发请求的。它会按”窗口还剩多少”去算这笔输出能不能放得下:输入占掉一部分,剩下的才是输出预算。你告诉它窗口只有 32k,输入一旦长一点,它会比 16384 更早就开始紧张,截断来得比预期更快。
还有一个对推理模型特别不友好的点:Bonsai 是推理模型,我又开了 --reasoning-preserve,而 thinking token 是实打实计入输出预算的。OpenAI 生态里 reasoning token 一直算在 completion tokens 里,本地端点也一样。所以界面上看着没写多少字,后台 thinking 可能已经吃掉几千上万 token,16384 的预算对推理模型来说比看起来紧得多。这是推理模型比闲聊模型更容易撞 maxTokens 的根本原因。
还差一个关键字段:maxTokensField
改大 maxTokens 还不够,还有一个很容易被忽略的点。
现在 OpenAI 生态里,max_tokens 和 max_completion_tokens 两套字段并存:老接口用 max_tokens,o1 系列的推理模型只认 max_completion_tokens,各家兼容端点跟进程度不一。老版本的 llama-server 只认 max_tokens,新版本两个都认。
问题在于,你不确定客户端发的是哪一个,也不确定自己那版 llama.cpp 认哪一个。两头一错配,就会出现”我明明改大了上限,实际请求却没生效”的灵异现象。
所以接本地 OpenAI 兼容端点时,最好显式写死:
1 | compat: |
一句话把不确定性消掉,不用赌版本。
我改完的配置
服务端启动脚本里顺手加了一行 --alias,给模型起个短名字:
1 | llama-server.exe ^ |
为什么加这个?因为 llama-server 默认拿模型文件的完整路径当 API 里的模型 id,拿一串带反斜杠的 Windows 路径当 id 又丑又容易对不上。加了 --alias bonsai-27b 之后,/v1/models 返回的 id 就是这个短名字,客户端配置照着写,干净也不容易出错。
然后 DSH 的 yaml 改成这样:
1 | - id: agent-default-model |
这里有个我实际踩到的坑,单独提一句:第一段 agent-default-model 里的 model 也必须跟着改成 bonsai-27b。我最开始只改了下面 models 列表里的 id,默认模型那段还挂着旧的路径 id,两个对不上,DSH 解析默认模型时找不到条目,你改的参数等于白改。yaml 里凡是引用模型 id 的地方,得一起换。
两条原则:
contextWindow要和 llama-server 的-c对齐,否则客户端按错误的窗口算余量,等于输出预算被二次压缩。maxTokens别一上来顶到 196k。它是单次输出上限,65536 对多数本地推理任务已经比较宽裕,留太多没意义。
改完保存就行,DSH desktop 版会热重载,不需要重启。
怎么验证是不是改对了?
先确认 DSH 实际命中的模型 ID:
1 | curl http://192.168.3.24:9931/v1/models \ |
返回里的 id 必须是 bonsai-27b,和 yaml 里写的完全一致。注意因为我启动脚本里开了 --api-key,/v1/* 下的接口全部要鉴权,curl 不带 header 会直接 401——如果哪天客户端突然全部请求失败、但 llama-server 日志显示服务正常,先查 401,别查模型。
也可以用:
1 | dsh --profile desktop --dump-config |
看看当前生效配置里,agent-default-model 和 models 条目是不是指向同一个 id,contextWindow 和 maxTokens 是不是已经变成新值。
不过上面两种都是间接确认。最硬的一招是直接打一次请求,把上限压到很小,看它断不断:
1 | curl http://192.168.3.24:9931/v1/chat/completions \ |
返回里看两个东西:finish_reason 应该是 length,usage.completion_tokens 应该正好卡在 200。两个都对上,说明 max_tokens 字段确实生效了。这个测试还能顺便演示上面说的”thinking 占预算”——推理模型经常数到一半就断,正文没出几个字,completion tokens 却已经满了。
关于”发送继续”
这个提示不是 bug,是兜底机制。DSH 会把已经生成的内容保留在对话里,让模型接着往下写。
但如果是在跑评测、长任务或者工具链,不建议靠它手动接龙。复制粘贴断点容易引入重复前缀,也可能破坏工具调用状态。更稳的做法还是把 maxTokens 和 contextWindow 配到位,让模型一次性跑完。
小结
这次踩坑最大的收获是:本地部署大模型时,服务端能跑多长,和客户端允许它跑多长,是两回事。
llama-server 的 -c、-n、KV cache 量化决定的是”能不能跑”;
DSH 的 contextWindow、maxTokens、maxTokensField 决定的是”允不允许跑”。
以后遇到”已达到输出 token 上限”这类提示,可以先看三点:
- llama-server 日志里有没有 context full / OOM;
- 客户端配置里
maxTokens是否过小,contextWindow有没有和服务端对齐; compat.maxTokensField是否显式写成了max_tokens。
本地跑模型就是这样,参数一层套一层,任何一个环节对不上,都会表现得像”模型不行”。其实很多时候,只是配置没对齐。