Agents-A1 回复被截断——是上下文长度还是停止符设置?

文章导读
输出总是断在半句时,先别急着把 max_tokens 调大,也别急着删 stop 配置。截断在行为上分成三种:模型确实走到了长度上限、生成过程中命中了某个停止符、以及输出其实已经生成完但客户端在超时或流式读取中途断开。这三类的修法完全不同,判断依据也不一样,所以第一步是把“断在哪里”变成可记录、可比较的事实,再决定改哪一项。
📋 目录
  1. A 先看截断位置是否总落在同一长度附近
  2. B 核对停止符与输出格式标记是否冲突
  3. C 排除调用侧超时和流式读取中断
  4. D 把原始返回完整落盘再比对
  5. E 调整后用同一批输入复测
A A

输出总是断在半句时,先别急着把 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 是否符合预期、原来被误触发的短语出现时输出是否完整。
  • 还要盯住的回归项:其他模板是否出现新的截断、长输入是否仍会撞长度上限、流式与非流式两条路径的结果是否一致、超时设置改动后是否有请求提前失败。

建议把复测结果和原始记录放在同一目录下对比,改动前后各留一份,后续再出类似问题就有基线可查。改动只解决其中一类原因时,另外两类仍可能复现,不要因为一次复测通过就认为所有截断都处理完了。