输出总是断在半句时,先别急着把 max_tokens 调大,也别急着删 stop 配置。截断在行为上分成三种:模型确实走到了长度上限、生成过程中命中了某个停止符、以及输出其实已经生成完但客户端在超时或流式读取中途断开。这三类的修法完全不同,判断依据也不一样,所以第一步是把“断在哪里”变成可记录、可比较的事实,再决定改哪一项。
截断先分三类看:返回长度是否稳定贴住上限、断点是否总跟在某个标记短语后面、客户端是否在超时或 EOF 处停下。长度稳定贴近 max_tokens,多是长度限制;断点随正文内容前后挪动,多是停止符被正文误触发;只有调用侧日志出现超时或连接中断,才优先怀疑客户端断开。动手改配置前先把原始返回落盘,改完用同一批输入复测。
先看截断位置是否总落在同一长度附近
做法很简单:对同一个 prompt 连发多次,最好带上温度不为 0 时也重复几轮,把每次返回的字符数和是否正常收尾记下来,然后看这组长度的分布形状。
- 如果每次都停在同一个数值附近,波动很小,而且这个数值和配置里的 max_tokens、或上下文剩余预算对得上,那基本是长度上限,属于模型侧正常结束。
- 如果长度忽长忽短,短的那些恰好停在某些固定短语后面,长的那次又完整输出,那更像是停止符命中,跟长度限制关系不大。
- 如果长度分布正常但结尾是硬切,比如正好卡在 UTF-8 多字节字符或 JSON 中间,要留意流式读取被切断的可能,下一节再排。
# 汇总每次请求的长度与结束原因,字段名以实际服务为准
jq -r '[.tag, .text_len, .finish_reason] | @tsv' logs/*.jsonl | sort -k2 -n这里的关键是“同一输入、多次重复”。只发一次的记录说明不了问题,长度是否收敛要到批量数据里才看得出来。
核对停止符与输出格式标记是否冲突
停止符的本意是让模型在该收尾的地方收尾,但如果配置里的停止符和模板格式标记重叠,正文一旦写出这个标记就会被当成结束信号。常见冲突来源有几类:\n\nHuman: 这类角色标记、<|im_end|> 这类特殊 token 的文本形式、以及 </answer>、###、\n\n 这种在正文里也可能出现的分隔串。
检查方法是把配置中的 stop 列表和模板里的格式标记逐条对照,看有没有同一串字符同时出现在两边。冲突时的具体表现是:模型输出到一个固定短语就停,把该短语从提示词或输入数据里去掉后,输出立刻变完整;或者同一份内容换个措辞就不再被截断。
# 通用配置片段,字段名和写法按你实际使用的服务替换
model: agents-a1
max_tokens: 1024
stop:
- "\n\nHuman:"
- "<|im_end|>"
- "</answer>"处理方向是让停止符尽量只出现在协议层、不出现在正文语义里。如果业务正文本身就要写这类标记,考虑换成更不可能被正文触发的结束标识,或在调用侧对命中做白名单判断,而不是直接调大长度上限。
排除调用侧超时和流式读取中断
模型侧正常结束和客户端断开,外观很像但日志对不上。对照检查点建议放在三处:服务端是否记录了本次生成已完成及其结束原因;调用侧 HTTP 状态码是否为超时或连接重置;流式场景下有没有收到最后一个数据块和结束标记。
- 服务端日志里能看到完成记录、调用侧日志里却是 read timeout 或 EOF:优先看客户端超时设置、读取循环和网络链路。
- 两边都没有完成记录,连接就断了:多半是链路或客户端提前退出,不是模型主动停。
- 服务端有完成记录,调用侧拿到的文本却变短:检查是否在拼接流式分片时丢块,或把某类分片当成结束跳出了循环。
断开时的记录特征通常是:时间戳对不上、缺少结束原因字段、字节数小于服务端统计值。把这几项写进日志模板,排查会快很多。
把原始返回完整落盘再比对
只看页面上被截断后的显示结果,很容易把“显示层截断”和“模型实际输出截断”混在一起。建议在调用封装里统一留档:请求参数、原始返回文本、结束原因、耗时、请求标识,一次一条 JSONL,便于事后按标识回放。
# 通用骨架,调用方法按实际 SDK 替换
import json, time, uuid, pathlib
def call_and_record(payload, tag):
rid = uuid.uuid4().hex[:8]
t0 = time.time()
resp = client.invoke(payload) # 替换为你的调用方式
raw = resp.get_raw_text() if hasattr(resp, "get_raw_text") else str(resp)
rec = {
"rid": rid,
"tag": tag,
"ts": time.strftime("%Y-%m-%d %H:%M:%S"),
"elapsed_ms": int((time.time() - t0) * 1000),
"request": payload, # 含 model / max_tokens / stop / temperature
"finish_reason": getattr(resp, "finish_reason", None),
"text_len": len(raw),
"raw_text": raw,
}
pathlib.Path("logs").mkdir(exist_ok=True)
with open(f"logs/{rid}.jsonl", "a", encoding="utf-8") as f:
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
return rec比对方式:对同一条记录,先看 finish_reason 是不是长度或停止符,再看 raw_text 结尾有没有残缺标记,最后把 raw_text 的长度和界面显示的长度对一下。三者一致,说明截断发生在模型侧;只有界面短,说明问题在展示或拼接环节。
调整后用同一批输入复测
改完 max_tokens、停止符或超时设置后,别换新样本,用原来那批触发过截断的输入复测,否则无法判断是修好了还是碰巧没触发。
- 固定输入集:把历次被截断的请求参数和输入原文存成一个文件,每次复测都跑这一份。
- 复测判据:长度是否不再贴住上限、结尾是否自然收束、finish_reason 是否符合预期、原来被误触发的短语出现时输出是否完整。
- 还要盯住的回归项:其他模板是否出现新的截断、长输入是否仍会撞长度上限、流式与非流式两条路径的结果是否一致、超时设置改动后是否有请求提前失败。
建议把复测结果和原始记录放在同一目录下对比,改动前后各留一份,后续再出类似问题就有基线可查。改动只解决其中一类原因时,另外两类仍可能复现,不要因为一次复测通过就认为所有截断都处理完了。