先说背景,不然后面容易看懵:我这边是两台机器分工,一台用 llama-server 把 Ternary-Bonsai-2-27B 跑成本地 OpenAI 兼容端点,另一台用 DeepSeek Harness(下面简称 DSH,一个支持自接 provider 的模型客户端)连上去用。一个是服务端,一个是客户端,记住这个分工就行,因为这次的坑恰好就卡在两者中间。

llama-server 的启动脚本我写得挺”满”:

1
2
3
4
5
6
7
8
9
10
11
12
llama-server.exe ^
-m "%MODEL%" ^
-ngl 99 ^
-c 196608 ^
--flash-attn on ^
--load-mode none ^
--cache-type-k q4_0 ^
--cache-type-v q4_0 ^
--reasoning-preserve ^
--api-key "your-api-key" ^
--host 0.0.0.0 ^
--port 9931

结果在 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
- id: llm-pi-ai
name: "@deepseek-ai/dsh-llm-pi-ai"
config:
providers:
local-llm:
displayName: bonsai-27b
apiKeyEnv: LOCAL_LLM_API_KEY
api: openai-completions
baseURL: http://192.168.3.24:9931/v1
models:
- id: 'D:\UnslothModels\...\Ternary-Bonsai-2-27B-PQ2_0.gguf'
name: Ternary-Bonsai-2-27B-PQ2_0
contextWindow: 32768
maxTokens: 16384
reasoningEffort: none

问题就出在这里。

配置项 原本值 实际影响
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
2
compat:
maxTokensField: max_tokens

一句话把不确定性消掉,不用赌版本。


我改完的配置

服务端启动脚本里顺手加了一行 --alias,给模型起个短名字:

1
2
3
4
5
llama-server.exe ^
-m "%MODEL%" ^
--alias bonsai-27b ^
-ngl 99 ^
...(其余参数不变)

为什么加这个?因为 llama-server 默认拿模型文件的完整路径当 API 里的模型 id,拿一串带反斜杠的 Windows 路径当 id 又丑又容易对不上。加了 --alias bonsai-27b 之后,/v1/models 返回的 id 就是这个短名字,客户端配置照着写,干净也不容易出错。

然后 DSH 的 yaml 改成这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
- id: agent-default-model
name: "@deepseek-ai/dsh-agent-default-model"
config:
provider: local-llm
model: bonsai-27b
- id: llm-pi-ai
name: "@deepseek-ai/dsh-llm-pi-ai"
config:
providers:
local-llm:
displayName: bonsai-27b
apiKeyEnv: LOCAL_LLM_API_KEY
api: openai-completions
baseURL: http://192.168.3.24:9931/v1
compat:
maxTokensField: max_tokens
models:
- id: bonsai-27b
name: Ternary-Bonsai-2-27B-PQ2_0
contextWindow: 196608
maxTokens: 65536
reasoningEffort: none

这里有个我实际踩到的坑,单独提一句:第一段 agent-default-model 里的 model 也必须跟着改成 bonsai-27b。我最开始只改了下面 models 列表里的 id,默认模型那段还挂着旧的路径 id,两个对不上,DSH 解析默认模型时找不到条目,你改的参数等于白改。yaml 里凡是引用模型 id 的地方,得一起换。

两条原则:

  • contextWindow 要和 llama-server 的 -c 对齐,否则客户端按错误的窗口算余量,等于输出预算被二次压缩。
  • maxTokens 别一上来顶到 196k。它是单次输出上限,65536 对多数本地推理任务已经比较宽裕,留太多没意义。

改完保存就行,DSH desktop 版会热重载,不需要重启。


怎么验证是不是改对了?

先确认 DSH 实际命中的模型 ID:

1
2
curl http://192.168.3.24:9931/v1/models \
-H "Authorization: Bearer your-api-key"

返回里的 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
2
3
4
curl http://192.168.3.24:9931/v1/chat/completions \
-H "Authorization: Bearer your-api-key" \
-H "Content-Type: application/json" \
-d '{"model": "bonsai-27b", "messages": [{"role":"user","content":"从1数到500"}], "max_tokens": 200}'

返回里看两个东西: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 上限”这类提示,可以先看三点:

  1. llama-server 日志里有没有 context full / OOM;
  2. 客户端配置里 maxTokens 是否过小,contextWindow 有没有和服务端对齐;
  3. compat.maxTokensField 是否显式写成了 max_tokens。

本地跑模型就是这样,参数一层套一层,任何一个环节对不上,都会表现得像”模型不行”。其实很多时候,只是配置没对齐。