长文档被截断时,先别急着换模型或调参数。把同一份原文拿出来,在返回结果里定位第一个缺失的段落,确认是尾部缺失、开头就少,还是中间跳段;再量清这次请求实际带进去多少字符、多少段;然后按段落边界重做分块,写入块序号并保留一句重叠;最后用同一份文档跑两轮,把每块的返回段号范围记进表里对比。这四步能把问题分成三类:输入规模接近上限、分块边界把段落切断、提示里没要求完整处理。分不清是哪一类时,换参数通常只是碰运气。
整篇文档一次送入后出现尾部缺失或中途断开,通常先怀疑两件事:输入规模已接近模型能稳定处理的上限,以及分块边界和提示缺少完整性约束。操作顺序是先给原文分段编号、量出字符数与段落数并写入日志,再按段落边界重分块、带块序号和一句重叠,最后用同一份文档跑两轮对比记录。边界也要清楚:如果量出来的长度明显不高却仍然缺段,问题更可能在提示写法或输出长度限制上,盲目缩小分块不会有用。
在返回结果里标出第一个缺失段落的位置
“被截断”这个说法太笼统,需要落到具体位置。常见三种:返回内容在文档后半段突然结束,后面全没有;返回一开头就跳过了前几段;中间某几段被模型合并或概括掉了,看起来完整,实际少了内容。三种情况的处理方向不同,前两种更像输入规模或输出长度问题,第三种更像提示没约束住。
做法是把原文按空行切成段落并编号,再把返回文本切段,逐段比对,找出第一个对不上的源段落,同时把它的前一段和后一段原文抄进记录里。前后各一段很关键,因为模型改写时开头几十个字可能被重写,只看缺失段本身容易误判。
import re
def paragraphs(text):
# 按空行切段;若文档有明确标题层级,可改为 re.split(r'\n(?=#{1,6}\s)', text)
return [p.strip() for p in re.split(r'\n\s*\n', text.strip()) if p.strip()]
src = paragraphs(open('doc.txt', encoding='utf-8').read())
out = paragraphs(open('reply.txt', encoding='utf-8').read())
out_head = [p[:20].replace('\n', ' ') for p in out]
for i, p in enumerate(src, 1):
key = p[:20].replace('\n', ' ')
if key not in out_head:
print('首个缺失段号:', i)
print('前一段:', src[i - 2][:80] if i > 1 else '(无)')
print('缺失段:', p[:80])
print('后一段:', src[i][:80] if i < len(src) else '(无)')
break
前 20 个字符的匹配只是粗定位,模型若改写措辞就会失配。遇到这种情况,人工翻到缺失段号前后各一段的原文,和返回内容对照一遍再下结论。
量一次输入的实际长度
先确认这次请求到底送进去多少字,别凭页面上的段落数估算。命令行下可以直接量:
wc -m doc.txt # 字符数(含空白)
wc -l doc.txt # 行数
grep -c '^$' doc.txt # 空行数,粗略对应段落分隔
如果要和请求日志对齐,用一小段脚本把长度算成结构化字段,和每次请求的 request id 一起打出去。字符数和 token 数不是一回事,中文一个字通常占接近一个 token,英文单词更低,具体要结合所用分词器确认,但字符数是先能稳定拿到的那一档指标。
import re, json, logging
text = open('doc.txt', encoding='utf-8').read()
paras = [p for p in re.split(r'\n\s*\n', text.strip()) if p.strip()]
logging.info(json.dumps({
'doc': 'doc.txt',
'chars': len(text),
'chars_no_space': len(re.sub(r'\s', '', text)),
'paragraphs': len(paras),
'max_para_chars': max(len(p) for p in paras),
}, ensure_ascii=False))
日志里同时记下返回文本的字符数和最后一段的段号。两者放在同一行,之后回看时能直接判断“输入多大时返回开始缺尾”,不用凭记忆复原当时的情况。
按段落边界重做分块
分块的目的是让每块独立可读、拼回去顺序不乱。按固定字符数硬切会切断段落,模型看到半句话,容易自行补全或直接跳过。改成先按标题或空行切段,再把段落累积到接近目标长度才换块,块内不破坏段落完整性。
目标长度怎么定:可以先按上一步量到的完整输入长度的一半到三分之二取一个保守值,跑一轮看返回是否齐全,再往上调。不要一上来就贴着上限试。
import re, json
def split_blocks(text, max_chars=3000):
paras = [p.strip() for p in re.split(r'\n\s*\n', text.strip()) if p.strip()]
groups, cur, size = [], [], 0
for p in paras:
if cur and size + len(p) > max_chars:
groups.append(cur)
cur, size = [], 0
cur.append(p)
size += len(p)
if cur:
groups.append(cur)
blocks = []
for idx, group in enumerate(groups, 1):
carry = groups[idx - 2][-1][-120:] if idx > 1 else ''
blocks.append({
'block_no': idx,
'total': len(groups),
'title_hint': group[0].split('\n')[0][:40],
'carry': carry,
'text': (carry + '\n\n' if carry else '') + '\n\n'.join(group),
})
return blocks
for b in split_blocks(open('doc.txt', encoding='utf-8').read()):
print(json.dumps({'block_no': b['block_no'], 'chars': len(b['text'])}, ensure_ascii=False))
open(f'block_{b["block_no"]:02d}.txt', 'w', encoding='utf-8').write(b['text'])
block_no 要写进文件名和提示里,total 一起带上,模型才知道自己在处理第几块、后面还有没有内容。carry 是上一块末尾保留的一句重叠,用来接住跨块断开的长段落,长度不宜过多,一句到两句即可。全篇段落顺序以原文为准,不要按长度重排,否则拼接时会出现逻辑错位。
在提示里加完整性要求
模型主动省略、概括、跳过后半部分是分块之后仍会遇到的另一类截断。提示里需要把“逐段处理、不许合并、遇断点写明续点”讲清楚,并要求输出保留段号,这样后续才能核对有没有漏。
你在处理同一份文档的第 {i} 块,共 {n} 块。
上一块末尾内容:「{carry}」,本块从该内容之后开始,不要重复输出上一块。
要求:
1. 逐段处理,不要省略、概括或合并段落;
2. 每个输出段落前保留原文段号,格式为 [P{段号}];
3. 如果本块内容超出你单次输出的长度,输出到断点为止,并另起一行写「续点:[P{段号}]」,不要跳过中间段落;
4. 只输出处理结果,不要附加总结、说明或建议。
第 3 条是减少尾部截断的主要手段:允许模型分段输出,但要求它明确标出停在哪里,而不是悄悄结束。第 2 条是后面核对的基础,没有段号就只能靠字符匹配,成本高很多。第三块之后的提示,建议把上一块的续点也带进去,避免同一段被处理两次。
复跑并记录每块返回是否齐全
验证分块方案是否真的解决缺口,靠一张逐块的记录表,不靠整体感觉。同一份文档跑两轮,两轮之间只改一个变量,比如只调分块长度,或只改提示,否则看不出是哪一项起了作用。
| 块序号 | 输入字符数 | 该块段号范围 | 返回段号范围 | 是否完整 | 缺失段落 |
|---|---|---|---|---|---|
| 1 | 待填 | P1–P18 | 待填 | 是/否 | 待填 |
| 2 | 待填 | P19–P35 | 待填 | 是/否 | 待填 |
| … | 待填 | 待填 | 待填 | 是/否 | 待填 |
判定规则可以写死两条:返回段号范围要和该块实际段号范围一致,中间出现跳跃或尾部不足都记为不完整;返回里出现「续点」标记,说明这块还需要再发一次续写请求,不能算完成。缺失段落一栏填具体段号,不要写“部分缺失”这类描述,否则第二轮没法对比。
两轮结果并排看:第一轮缺在哪几块、第二轮是否仍在同样的块号缺,还是缺口随分块变小而前移。如果分块缩小后缺口消失,说明原输入规模是主因;如果缺口位置和块号无关、总是在返回末尾附近,说明更可能是输出长度受限,需要在提示第 3 条和续写机制上处理,而不是继续切小块。