输入上下文很长,不代表模型可以一次输出很多;大批量结构化数据要优先分批,无法分批时再做续传和碎片恢复。
大模型返回被截断怎么办?从分批处理到多轮续传
做结构化数据提取时,很容易遇到一个问题:
输入明明没有超过上下文窗口
为什么返回的 JSON 还是只写了一半?
原因在于:
Context Window
≠
Max Output Tokens
上下文窗口决定模型一次能“看到多少”。
最大输出长度决定模型一次能“说多少”。
所以即使模型能够读取非常长的招聘文件,也可能输出到:
[
{
"company": "腾讯",
"position": "Java开发"
},
{
"company": "阿里",
"position":
就直接结束。
这时不是 Structured Output 失效,而是 JSON 在生成过程中被截断了。
1. 为什么 .entity() 救不了截断?
正常情况下:
List<Job> jobs = chatClient.prompt()
.user(text)
.call()
.entity(new ParameterizedTypeReference<>() {});
流程是:
LLM
↓
完整 JSON
↓
Jackson
↓
List<Job>
但如果模型只返回:
[
{...},
{
"company":
这已经不是合法 JSON。
因此:
Structured Output
只能解决:
模型应该按照什么结构返回。
解决不了:
模型有没有足够的输出空间把它全部返回。
这是两个完全不同的问题。
2. 最优方案:能拆就先拆输入
如果输入天然有边界,例如:
CSV
Excel
数据库记录
表格
最好的办法不是等模型被截断以后再抢救,而是在调用模型之前主动分批。
例如:
100 条数据
↓
每批 6 条
↓
Batch 1 → LLM
Batch 2 → LLM
Batch 3 → LLM
...
↓
合并结果
这样每次模型只需要返回少量 JSON:
小输入
↓
小输出
↓
完整 JSON
↓
直接反序列化
代码思路:
for (List<Row> batch : batches) {
List<Job> result = chatClient.prompt()
.user(buildPrompt(batch))
.call()
.entity(
new ParameterizedTypeReference<>() {}
);
allJobs.addAll(result);
}
这其实就是经典的:
Chunking / Batch Processing
相比“先生成一个巨大 JSON,再想办法修”,这种方案通常更稳定。
3. 为什么分批是首选?
因为它同时解决几个问题。
假设一次让模型处理 100 条:
输出时间长
Token 多
容易超时
容易截断
JSON 越长越容易出错
失败后重试成本很高
拆成小批次以后:
6 条成功
→ 保存
下一批失败
→ 只重试这一批
因此生产环境里要形成一个习惯:
不要让一次 LLM 调用承担过大的工作单元。
这和传统后端批处理其实是一个思想。
4. 但不是所有数据都能拆
例如:
一篇很长的招聘公告
一个网页
一张图片
一段没有明显边界的自然语言
你不能随便从中间切一刀。
因为:
前半段:
“岗位要求如下……”
后半段:
“以上岗位工作地点均为武汉……”
强行拆分可能破坏语义。
这种场景就需要第二套方案:
多轮续传。
5. 多轮续传怎么做?
第一轮正常发送:
原始数据
+
结构化格式要求
如果结果明显没有结束,就继续问:
你之前返回的结果不完整,
继续返回剩余内容。
这时候需要:
ChatMemory
保存前几轮上下文。
整体:
原始数据
↓
LLM 第 1 轮
↓
部分结果
↓
ChatMemory
↓
“继续”
↓
LLM 第 2 轮
↓
剩余结果
↓
...
所以 ChatMemory 在这里不是为了普通聊天,而是:
让模型记得自己上一轮生成到了哪里。
6. 续传一定要设置退出条件
不能无限:
继续
继续
继续
继续
否则模型可能进入循环。
通常需要同时设置:
最大续传轮数
+
完整结果检测
+
重复生成检测
例如:
for (int i = 0; i < MAX_ROUNDS; i++) {
String result = callModel();
parse(result);
if (isCompleted(result)) {
break;
}
if (isRepeated(result)) {
break;
}
}
其中:
MAX_ROUNDS
就是最重要的安全阀。
所有自动续写、ReAct、Agent Loop 都必须设置最大迭代次数。
7. 最大难题其实是残缺 JSON
假设第一轮结束在:
{
"company": "腾讯",
"position": "Java开
第二轮可能继续:
发",
"salary": "25k"
}
这两段单独都不是合法 JSON。
所以一种比较实用的办法不是:
每一轮都强行解析整个 JSON 数组。
而是:
只提取当前已经完整的 JSON Object。
例如:
[
{完整对象1},
{完整对象2},
{残缺对象3
先保存:
对象1
对象2
对象3留下,下一轮继续拼。
8. 括号计数提取完整对象
一个常见思路是遍历模型返回内容。
遇到:
{
层级:
+1
遇到:
}
层级:
-1
当层级重新回到 0:
说明一个 JSON Object 完整结束
例如:
{
"company": "A",
"job": "Java"
}
可以直接提取出来。
当然还必须处理:
字符串中的 { }
转义引号 \"
Markdown ```json
否则容易误判。
所以本质上是在做:
Output Fragment
↓
识别完整 Object
↓
立即解析保存
↓
残缺 Fragment
↓
等待下一轮
9. 不要追求“最后拼成一个巨大 JSON”
这一点其实很值得沉淀。
假设最终需要 100 条记录。
没必要:
5 轮返回
↓
拼成一个超级长 JSON
↓
最后一次性解析
更合理的是:
Round 1
→ 提取 20 条完整记录
Round 2
→ 再提取 18 条
Round 3
→ 再提取 21 条
直接不断:
itemList.add(...);
最终:
List<Job>
就是结果。
这比维护一个巨大字符串稳定得多。
10. AI 数据处理要接受 Partial Success
例如:
第一轮:成功 20 条
第二轮:成功 20 条
第三轮:成功 15 条
第四轮:模型超时
现在已经有:
55 条
一种错误处理方式是:
throw exception;
然后整个任务:
失败
前面 55 条全部丢弃。
很多 AI 数据采集场景更适合:
catch (Exception e) {
break;
}
return itemList;
也就是:
Best Effort / Partial Success
能保留多少有效数据,就先保留多少。
11. 最终应该采用双策略
真正比较实用的设计不是只选一个方案,而是根据数据类型自动选择:
Input
↓
能不能安全拆分?
/ \
能 不能
↓ ↓
Batch Processing Multi-turn
↓ Continuation
Structured Output ↓
↓ Fragment Parser
└───────┬─────────┘
↓
List<DTO>
例如:
CSV / Excel
→ 输入预拆分
网页 / 图片 / 长文本
→ 多轮续传
可以概括成一句:
能拆就拆,拆不了再续。
12. 和前面知识怎么串起来?
这个问题其实把之前很多 Spring AI 能力串起来了:
BeanOutputConverter
→ 告诉模型返回什么结构
ChatMemory
→ 让续传记住上一轮
Function Call
→ 必要时抓取网页数据
Tool / MCP
→ 获取外部内容
Fragment Parser
→ 恢复残缺 JSON
Batch Processing
→ 从源头控制输出大小
所以完整的数据采集链路可能是:
Input
↓
判断输入类型
↓
CSV / Excel ─────→ Batch
↓
Text / Web ──────→ Multi-turn
↓
Function Call
↓
Raw Data
↓
LLM
↓
Structured Output
↓
Fragment Recovery
↓
List<Record>
最后总结
这篇最值得沉淀的不是“怎么修 JSON”,而是 长输出治理思维。
可以记住这一组原则:
- Context Window 和 Max Output Tokens 是两回事。
- 能预拆分的数据,优先 Batch,而不是生成超大 JSON。
- 无法拆分的数据,再使用 ChatMemory 做多轮续传。
- 每轮只提取完整 JSON 对象,不依赖一次得到完整数组。
- 续传必须有最大轮数和重复检测。
- AI 数据处理允许 Partial Success,保留已经成功的结果。
最值得记住的一句话:
处理 LLM 大批量输出时,不要赌模型“一次全吐完”;先把任务切小,实在切不了,再用续传和碎片恢复兜底。
这其实也是一个很通用的 AI 工程原则:
小任务
+
可恢复
+
可重试
+
部分成功
通常比一次巨大的 LLM 调用稳定得多。