二哥项目资料

解决大模型返回截断的问题

正文 18,899 字 #18 原文
本文目录 · 42 节

星球:Java进阶之路&二哥编程星球 | 专栏:求职派 OpenClaw 架构 | 作者:沉默王二 | 发布:2026-09-13

解决大模型返回截断的问题

解决大模型返回截断的问题

现在的大模型,基本上都是 1M(100 万 tokens)的超长上下文,比如说 DeepSeek V4.1 Flash。

那可能有小伙伴会产生这样一种错觉,既然一次性能喂进去几十万字的数据资料,那让模型一次性解析批量岗位数据并输出包含 15 个字段的 JSON 数组,理论上不会有什么压力。

但在真实的业务落地过程中,一旦大模型返回的 JSON 数据量超过了单次最大输出参数(Max Output Tokens / max_tokens),就会出现截断问题。

图片.png

大模型截断输出,看的是 输出长度参数(Max Output Tokens / max_tokens ),而不是上下文长度。

输入和输出不一回事。

以 DeepSeek V4.1 Flash 为例,虽然它的理论最大输出允许配置到 384K,但在未显式指定 max_tokens 的情况下,非思考模式下的单次输出上限 仅仅只有 8K(8192 tokens)。

8K tokens 换算成具体的业务数据到底能容纳多少呢?

在求职派中,提取一条岗位记录包含公司名称、岗位、薪资、学历、经验、职责等 15 个结构化字段。

中文字符、英文字段名加上大括号、双引号、逗号等 JSON 语法符号,一条完整的岗位对象实测需要占用 120 到 200 个 token。在默认 8K 的输出限制下,扣除外层数组结构与格式前缀,单次最多只能完整吐出 40 到 60 条岗位数据。

哪怕显式把 max_tokens 拉满到 384K,也必须做好防御。

因为大模型自回归逐字生成几十万 token 会耗费漫长时间,很容易触发网关代理和后端的 HTTP 超时,且超长生成容易出现语法错误;再加上业务数据规模是动态未知的,任何固定的静态输出上限在海量批处理面前终有触顶被击穿的时刻。

而一旦发生截断,残损的 JSON 就会导致反序列化崩溃。

图片.png

01、大模型返回为什么会被截断?

先看一下问题现场。

求职派的岗位数据采集模块,负责把用户提交的招聘信息(文本、Excel、CSV、网页链接)交给大模型解析,提取出公司名称、岗位、薪资、学历要求等 15 个结构化字段,返回一个 JSON 数组。

方法签名上其实已经标注了,数据太长会导致异常。

图片.png

准备一波测试数据,30 条岗位信息,包含事业单位、央国企、民企各类招聘,在管理端提交。

图片.png

执行之后,控制台直接报错了,提示返回结果无法转换为目标对象。

图片.png

翻到日志末尾,原因一目了然,大模型返回的不是一个完整的 JSON 字符串,在某个字段中间就被截断了。

图片.png

决定大模型是否截断的,是单次最大输出参数(Max Output Tokens),而不是 1M 的输入上下文。

在 Spring AI 等框架中,如果没有显式注入 max_tokens 参数,DeepSeek V4.1 Flash 就会按照非思考模式默认的 8K(8192 tokens)上限执行输出。

求职派的测试数据中虽然只提交了 30 条岗位,但由于每条岗位包含公司名称、岗位职责、任职要求等 15 个字段,加上 JSON 格式的英文字段名与结构符号,单条消耗的 token 往往超过 200 个;再叠加测试数据中部分较长的富文本招聘通告,30 条记录累加起来非常容易击穿单次输出配额。

输出一旦达到上限,大模型便不会补全花括号,也不会闭合数组,导致后端的反序列化在解析残损字符串时抛出异常。

02、怎么让大模型“接着说”?

既然一次返回不完,就让大模型分多次返回。

每次返回的内容先存起来,然后告诉大模型“你之前的结果不完整,继续返回剩余的内容”,它就会接着上次截断的地方继续输出。

这依赖一个前提,大模型需要“记住”之前说了什么。

Spring AI 的 ChatMemory 正好提供了这个能力。它会把每一轮对话的消息都保存下来,下一轮对话时把历史消息一起发给模型,模型就有了上下文。

初始化对话上下文

求职派的实现是这样的。

先创建一个对话记忆实例,设定最多保留 10 轮消息,然后注入系统提示词,告诉大模型它是一个“专业的数据挖掘者”。

Java

ChatMemory chatMemory = MessageWindowChatMemory
    .builder().maxMessages(10).build();
String chatId = RandomUtil.randomString(6);
SystemMessage systemMessage = new SystemMessage(GATHER_SYSTEM_PROMPT);
chatMemory.add(chatId, systemMessage);

第一轮对话时,用户消息的内容就是需要采集的原始数据,再拼接上结构化输出的格式说明。

图片.png

这里用了 Spring AI 的提示词模板做文本替换,格式说明由结构化输出转换器自动生成,它会根据目标 Java record 的字段定义生成对应的 JSON Schema,让大模型知道该返回什么结构的数据。

从第二轮开始,用户消息就变成了一句固定的续传指令。

Java

if (cnt == 0) {
    msg = UserMessage.builder()
        .text(new PromptTemplate("{text}.{format}")
            .render(Map.of("text", text, "format", converter.getFormat())))
        .build();
} else {
    msg = new UserMessage("你之前返回的结果不完整,继续返回剩余的内容");
}

什么时候停下来?

多轮对话的难点不在“怎么续传”,而在“什么时候停”。

求职派设置了两个退出条件。第一个是最多对话 10 轮,避免死循环。第二个是启发式的,如果大模型返回的内容以 ```````````结尾(Markdown

还有一种特殊情况,大模型没有接着上次截断的地方继续,而是重新返回了一份完整的数据。这时候继续对话下去也是在重复解析,求职派检测到这种情况后会主动退出循环。

图片.png

过程中还注册了一个网页抓取工具(Function Calling),让大模型在遇到 HTTP 链接类型的输入时,能够主动调用工具去抓取网页中的表格内容。这个工具内部用 Jsoup 解析 HTML,提取 `` 元素的内容。

多轮对话解决的是“模型返回不完”的问题,但它引出了一个更棘手的问题。每次返回的 JSON 都是残缺的,怎么把它们拼成完整的数据?

03、碎片化的 JSON 怎么拼回完整数据?

大模型每次返回的内容格式是不可控的。实测下来,至少会出现以下四种情况。

  • 以 ```json开头,包含完整的

  • 没有 Markdown 标记,直接就是 JSON 内容

  • 从某个字段的值中间截断,比如 panyName": "昂瑞微" ( com 被截掉了)

  • 同一条数据在上一轮的末尾和这一轮的开头各出现了一半

求职派的处理策略是 逐条提取。不去拼完整的 JSON 数组,而是每次返回的内容,先把其中能独立解析的 JSON 对象(一条岗位数据)提取出来,剩余解析不了的碎片保留到下一轮继续拼接。

碎片提取算法

核心算法是括号计数法。遍历字符串的每个字符,遇到 { 就加一,遇到 } 就减一,当计数器回到 0 的时候,说明找到了一个完整的 JSON 对象。

图片.png

这里有一个细节需要处理。JSON 的字符串值里也可能包含 { 和 } 字符。算法通过追踪引号状态(是否在字符串内部)来避免误判,遇到转义引号 \" 则跳过。

每提取出一个完整的 JSON 对象,就从 StringBuilder 中删除已提取的部分,剩余的碎片留给下一轮拼接。末尾不完整的属性(比如 "companyName": "昂瑞 这种截断到一半的字段)会被正则表达式清理掉,避免影响后续拼接。

格式预处理

在调用括号计数法之前,还需要做一步预处理,清理大模型返回内容中的 Markdown 标记,并处理截断点的衔接。

图片.png

预处理逻辑会检查上一轮剩余碎片的末尾和这一轮内容的开头,判断它们能否直接拼接。如果上一轮末尾刚好停在一个字段值的引号处,而这一轮以 { 开头(说明大模型跳过了截断点,重新开始了一条数据),就自动补上 }, 来闭合上一条不完整的数据。

这套碎片提取机制确实能把多轮返回的 JSON 碎片拼回完整数据,但拼接逻辑容易出 corner case。

04、能不能从源头就避免截断?

可以,前提是输入数据能够按行拆分。

对于 CSV 和 Excel 这两种文件类型,数据天然是行结构的。每一行就是一条完整的招聘信息,从任意行处截断都不会破坏单条数据的完整性。

求职派的做法是在读取文件时就进行预拆分,然后每批 6 条数据加上标题行一起发给大模型。6 条数据量不大,大模型一次返回就能覆盖完整的 JSON 结果,根本不会触发截断。

CSV 的拆分

CSV 文件按换行符切分,第一行作为表头,其余每一行都是一条数据。

图片.png

Excel 的拆分

Excel 的处理借助了 FastExcel 的 ReadListener 回调机制,在读取过程中就把表头和每一行数据分别提取出来。

图片.png

分批调用

拿到拆分后的数据,就可以按每批 6 条的粒度循环调用大模型了。

图片.png

两种方案怎么选?

输入预拆分的实现更简单,效果也更稳定。因为单批数据量小,大模型一次就能返回完整的结果,不需要处理碎片拼接这些麻烦事。

图片.png

但它有一个硬性前提,输入数据必须是可以按行拆分的结构化数据(CSV、Excel)。对于纯文本、图片、网页链接这类输入,没有明确的分割边界,强行拆分反而会破坏数据的完整性。

所以求职派的策略是 CSV 和 Excel 走输入预拆分,其他类型走多轮对话续传。两种方案不是非此即彼的替代关系,而是根据输入类型自动选择的互补方案。

需要测试验证的小伙伴,可以使用 resources/data 目录下提供的测试数据文件进行验证。

ending

【大模型的输出上限是强制约束,不会因为你的 JSON 写到一半就网开一面。求职派的做法说不上多精巧,多轮对话续传加输入预拆分,一个兜底一个治本,能拆就拆,拆不了就让模型接着返回。】

我们下期见。

如何把求职派写到简历上?

项目名称:求职派(JobClaw)

项目简介:OpenClaw 风格的多 Agent 求职系统,支持微信/钉钉/飞书等 IM 渠道接入,消息经意图分类后路由到可插拔的业务 Agent,内置岗位数据采集、岗位检索、岗位推荐等核心能力。

技术栈:Java 21、Spring Boot 4.0、Spring AI 2.0、LangGraph4J、JPA/Hibernate、FastExcel、Jsoup

核心职责:

  • 基于 Spring AI 的 ChatMemory 实现多轮对话续传机制,解决大模型单次输出超限导致的 JSON 截断问题,最大支持 10 轮自动续传

  • 设计并实现括号计数法 JSON 碎片提取算法,支持从不完整的大模型返回中逐条提取有效 JSON 对象,兼容 Markdown 标记清理、截断点自动补全、不完整属性清理等边界情况

  • 实现 CSV/Excel 文件的输入预拆分策略,每批 6 条数据分批调用大模型,从源头规避截断问题,相比多轮对话方案减少了 60% 以上的模型调用次数

  • 基于 Spring AI 的 BeanOutputConverter 实现结构化输出,自动生成包含 15 个字段的 JSON Schema 格式说明,引导大模型返回符合目标 record 结构的数据

  • 通过 Function Calling 注册网页抓取工具,使用 Jsoup 解析 HTML 表格元素,让大模型在遇到 HTTP 链接输入时能够自动获取网页中的招聘数据

现在的大模型,基本上都是 1M(100 万 tokens)的超长上下文,比如说 DeepSeek V4.1 Flash。

那可能有小伙伴会产生这样一种错觉,既然一次性能喂进去几十万字的数据资料,那让模型一次性解析批量岗位数据并输出包含 15 个字段的 JSON 数组,理论上不会有什么压力。

但在真实的业务落地过程中,一旦大模型返回的 JSON 数据量超过了单次最大输出参数(Max Output Tokens / max_tokens),就会出现截断问题。

图片.png

大模型截断输出,看的是 输出长度参数(Max Output Tokens / max_tokens ),而不是上下文长度。

输入和输出不一回事。

以 DeepSeek V4.1 Flash 为例,虽然它的理论最大输出允许配置到 384K,但在未显式指定 max_tokens 的情况下,非思考模式下的单次输出上限 仅仅只有 8K(8192 tokens)。

8K tokens 换算成具体的业务数据到底能容纳多少呢?

在求职派中,提取一条岗位记录包含公司名称、岗位、薪资、学历、经验、职责等 15 个结构化字段。

中文字符、英文字段名加上大括号、双引号、逗号等 JSON 语法符号,一条完整的岗位对象实测需要占用 120 到 200 个 token。在默认 8K 的输出限制下,扣除外层数组结构与格式前缀,单次最多只能完整吐出 40 到 60 条岗位数据。

哪怕显式把 max_tokens 拉满到 384K,也必须做好防御。

因为大模型自回归逐字生成几十万 token 会耗费漫长时间,很容易触发网关代理和后端的 HTTP 超时,且超长生成容易出现语法错误;再加上业务数据规模是动态未知的,任何固定的静态输出上限在海量批处理面前终有触顶被击穿的时刻。

而一旦发生截断,残损的 JSON 就会导致反序列化崩溃。

图片.png

01、大模型返回为什么会被截断?

先看一下问题现场。

求职派的岗位数据采集模块,负责把用户提交的招聘信息(文本、Excel、CSV、网页链接)交给大模型解析,提取出公司名称、岗位、薪资、学历要求等 15 个结构化字段,返回一个 JSON 数组。

方法签名上其实已经标注了,数据太长会导致异常。

图片.png

准备一波测试数据,30 条岗位信息,包含事业单位、央国企、民企各类招聘,在管理端提交。

图片.png

执行之后,控制台直接报错了,提示返回结果无法转换为目标对象。

图片.png

翻到日志末尾,原因一目了然,大模型返回的不是一个完整的 JSON 字符串,在某个字段中间就被截断了。

图片.png

决定大模型是否截断的,是单次最大输出参数(Max Output Tokens),而不是 1M 的输入上下文。

在 Spring AI 等框架中,如果没有显式注入 max_tokens 参数,DeepSeek V4.1 Flash 就会按照非思考模式默认的 8K(8192 tokens)上限执行输出。

求职派的测试数据中虽然只提交了 30 条岗位,但由于每条岗位包含公司名称、岗位职责、任职要求等 15 个字段,加上 JSON 格式的英文字段名与结构符号,单条消耗的 token 往往超过 200 个;再叠加测试数据中部分较长的富文本招聘通告,30 条记录累加起来非常容易击穿单次输出配额。

输出一旦达到上限,大模型便不会补全花括号,也不会闭合数组,导致后端的反序列化在解析残损字符串时抛出异常。

02、怎么让大模型“接着说”?

既然一次返回不完,就让大模型分多次返回。

每次返回的内容先存起来,然后告诉大模型“你之前的结果不完整,继续返回剩余的内容”,它就会接着上次截断的地方继续输出。

这依赖一个前提,大模型需要“记住”之前说了什么。

Spring AI 的 ChatMemory 正好提供了这个能力。它会把每一轮对话的消息都保存下来,下一轮对话时把历史消息一起发给模型,模型就有了上下文。

初始化对话上下文

求职派的实现是这样的。

先创建一个对话记忆实例,设定最多保留 10 轮消息,然后注入系统提示词,告诉大模型它是一个“专业的数据挖掘者”。

Java

ChatMemory chatMemory = MessageWindowChatMemory
    .builder().maxMessages(10).build();
String chatId = RandomUtil.randomString(6);
SystemMessage systemMessage = new SystemMessage(GATHER_SYSTEM_PROMPT);
chatMemory.add(chatId, systemMessage);

第一轮对话时,用户消息的内容就是需要采集的原始数据,再拼接上结构化输出的格式说明。

图片.png

这里用了 Spring AI 的提示词模板做文本替换,格式说明由结构化输出转换器自动生成,它会根据目标 Java record 的字段定义生成对应的 JSON Schema,让大模型知道该返回什么结构的数据。

从第二轮开始,用户消息就变成了一句固定的续传指令。

Java

if (cnt == 0) {
    msg = UserMessage.builder()
        .text(new PromptTemplate("{text}.{format}")
            .render(Map.of("text", text, "format", converter.getFormat())))
        .build();
} else {
    msg = new UserMessage("你之前返回的结果不完整,继续返回剩余的内容");
}

什么时候停下来?

多轮对话的难点不在“怎么续传”,而在“什么时候停”。

求职派设置了两个退出条件。第一个是最多对话 10 轮,避免死循环。第二个是启发式的,如果大模型返回的内容以 ```````````结尾(Markdown

还有一种特殊情况,大模型没有接着上次截断的地方继续,而是重新返回了一份完整的数据。这时候继续对话下去也是在重复解析,求职派检测到这种情况后会主动退出循环。

图片.png

过程中还注册了一个网页抓取工具(Function Calling),让大模型在遇到 HTTP 链接类型的输入时,能够主动调用工具去抓取网页中的表格内容。这个工具内部用 Jsoup 解析 HTML,提取 `` 元素的内容。

多轮对话解决的是“模型返回不完”的问题,但它引出了一个更棘手的问题。每次返回的 JSON 都是残缺的,怎么把它们拼成完整的数据?

03、碎片化的 JSON 怎么拼回完整数据?

大模型每次返回的内容格式是不可控的。实测下来,至少会出现以下四种情况。

  • 以 ```json开头,包含完整的

  • 没有 Markdown 标记,直接就是 JSON 内容

  • 从某个字段的值中间截断,比如 panyName": "昂瑞微" ( com 被截掉了)

  • 同一条数据在上一轮的末尾和这一轮的开头各出现了一半

求职派的处理策略是 逐条提取。不去拼完整的 JSON 数组,而是每次返回的内容,先把其中能独立解析的 JSON 对象(一条岗位数据)提取出来,剩余解析不了的碎片保留到下一轮继续拼接。

碎片提取算法

核心算法是括号计数法。遍历字符串的每个字符,遇到 { 就加一,遇到 } 就减一,当计数器回到 0 的时候,说明找到了一个完整的 JSON 对象。

图片.png

这里有一个细节需要处理。JSON 的字符串值里也可能包含 { 和 } 字符。算法通过追踪引号状态(是否在字符串内部)来避免误判,遇到转义引号 \" 则跳过。

每提取出一个完整的 JSON 对象,就从 StringBuilder 中删除已提取的部分,剩余的碎片留给下一轮拼接。末尾不完整的属性(比如 "companyName": "昂瑞 这种截断到一半的字段)会被正则表达式清理掉,避免影响后续拼接。

格式预处理

在调用括号计数法之前,还需要做一步预处理,清理大模型返回内容中的 Markdown 标记,并处理截断点的衔接。

图片.png

预处理逻辑会检查上一轮剩余碎片的末尾和这一轮内容的开头,判断它们能否直接拼接。如果上一轮末尾刚好停在一个字段值的引号处,而这一轮以 { 开头(说明大模型跳过了截断点,重新开始了一条数据),就自动补上 }, 来闭合上一条不完整的数据。

这套碎片提取机制确实能把多轮返回的 JSON 碎片拼回完整数据,但拼接逻辑容易出 corner case。

04、能不能从源头就避免截断?

可以,前提是输入数据能够按行拆分。

对于 CSV 和 Excel 这两种文件类型,数据天然是行结构的。每一行就是一条完整的招聘信息,从任意行处截断都不会破坏单条数据的完整性。

求职派的做法是在读取文件时就进行预拆分,然后每批 6 条数据加上标题行一起发给大模型。6 条数据量不大,大模型一次返回就能覆盖完整的 JSON 结果,根本不会触发截断。

CSV 的拆分

CSV 文件按换行符切分,第一行作为表头,其余每一行都是一条数据。

图片.png

Excel 的拆分

Excel 的处理借助了 FastExcel 的 ReadListener 回调机制,在读取过程中就把表头和每一行数据分别提取出来。

图片.png

分批调用

拿到拆分后的数据,就可以按每批 6 条的粒度循环调用大模型了。

图片.png

两种方案怎么选?

输入预拆分的实现更简单,效果也更稳定。因为单批数据量小,大模型一次就能返回完整的结果,不需要处理碎片拼接这些麻烦事。

图片.png

但它有一个硬性前提,输入数据必须是可以按行拆分的结构化数据(CSV、Excel)。对于纯文本、图片、网页链接这类输入,没有明确的分割边界,强行拆分反而会破坏数据的完整性。

所以求职派的策略是 CSV 和 Excel 走输入预拆分,其他类型走多轮对话续传。两种方案不是非此即彼的替代关系,而是根据输入类型自动选择的互补方案。

需要测试验证的小伙伴,可以使用 resources/data 目录下提供的测试数据文件进行验证。

ending

【大模型的输出上限是强制约束,不会因为你的 JSON 写到一半就网开一面。求职派的做法说不上多精巧,多轮对话续传加输入预拆分,一个兜底一个治本,能拆就拆,拆不了就让模型接着返回。】

我们下期见。

如何把求职派写到简历上?

项目名称:求职派(JobClaw)

项目简介:OpenClaw 风格的多 Agent 求职系统,支持微信/钉钉/飞书等 IM 渠道接入,消息经意图分类后路由到可插拔的业务 Agent,内置岗位数据采集、岗位检索、岗位推荐等核心能力。

技术栈:Java 21、Spring Boot 4.0、Spring AI 2.0、LangGraph4J、JPA/Hibernate、FastExcel、Jsoup

核心职责:

  • 基于 Spring AI 的 ChatMemory 实现多轮对话续传机制,解决大模型单次输出超限导致的 JSON 截断问题,最大支持 10 轮自动续传

  • 设计并实现括号计数法 JSON 碎片提取算法,支持从不完整的大模型返回中逐条提取有效 JSON 对象,兼容 Markdown 标记清理、截断点自动补全、不完整属性清理等边界情况

  • 实现 CSV/Excel 文件的输入预拆分策略,每批 6 条数据分批调用大模型,从源头规避截断问题,相比多轮对话方案减少了 60% 以上的模型调用次数

  • 基于 Spring AI 的 BeanOutputConverter 实现结构化输出,自动生成包含 15 个字段的 JSON Schema 格式说明,引导大模型返回符合目标 record 结构的数据

  • 通过 Function Calling 注册网页抓取工具,使用 Jsoup 解析 HTML 表格元素,让大模型在遇到 HTTP 链接输入时能够自动获取网页中的招聘数据

现在的大模型,基本上都是 1M(100 万 tokens)的超长上下文,比如说 DeepSeek V4.1 Flash。

那可能有小伙伴会产生这样一种错觉,既然一次性能喂进去几十万字的数据资料,那让模型一次性解析批量岗位数据并输出包含 15 个字段的 JSON 数组,理论上不会有什么压力。

但在真实的业务落地过程中,一旦大模型返回的 JSON 数据量超过了单次最大输出参数(Max Output Tokens / max_tokens),就会出现截断问题。

图片.png

大模型截断输出,看的是 输出长度参数(Max Output Tokens / max_tokens ),而不是上下文长度。

输入和输出不一回事。

以 DeepSeek V4.1 Flash 为例,虽然它的理论最大输出允许配置到 384K,但在未显式指定 max_tokens 的情况下,非思考模式下的单次输出上限 仅仅只有 8K(8192 tokens)。

8K tokens 换算成具体的业务数据到底能容纳多少呢?

在求职派中,提取一条岗位记录包含公司名称、岗位、薪资、学历、经验、职责等 15 个结构化字段。

中文字符、英文字段名加上大括号、双引号、逗号等 JSON 语法符号,一条完整的岗位对象实测需要占用 120 到 200 个 token。在默认 8K 的输出限制下,扣除外层数组结构与格式前缀,单次最多只能完整吐出 40 到 60 条岗位数据。

哪怕显式把 max_tokens 拉满到 384K,也必须做好防御。

因为大模型自回归逐字生成几十万 token 会耗费漫长时间,很容易触发网关代理和后端的 HTTP 超时,且超长生成容易出现语法错误;再加上业务数据规模是动态未知的,任何固定的静态输出上限在海量批处理面前终有触顶被击穿的时刻。

而一旦发生截断,残损的 JSON 就会导致反序列化崩溃。

图片.png

01、大模型返回为什么会被截断?

先看一下问题现场。

求职派的岗位数据采集模块,负责把用户提交的招聘信息(文本、Excel、CSV、网页链接)交给大模型解析,提取出公司名称、岗位、薪资、学历要求等 15 个结构化字段,返回一个 JSON 数组。

方法签名上其实已经标注了,数据太长会导致异常。

图片.png

准备一波测试数据,30 条岗位信息,包含事业单位、央国企、民企各类招聘,在管理端提交。

图片.png

执行之后,控制台直接报错了,提示返回结果无法转换为目标对象。

图片.png

翻到日志末尾,原因一目了然,大模型返回的不是一个完整的 JSON 字符串,在某个字段中间就被截断了。

图片.png

决定大模型是否截断的,是单次最大输出参数(Max Output Tokens),而不是 1M 的输入上下文。

在 Spring AI 等框架中,如果没有显式注入 max_tokens 参数,DeepSeek V4.1 Flash 就会按照非思考模式默认的 8K(8192 tokens)上限执行输出。

求职派的测试数据中虽然只提交了 30 条岗位,但由于每条岗位包含公司名称、岗位职责、任职要求等 15 个字段,加上 JSON 格式的英文字段名与结构符号,单条消耗的 token 往往超过 200 个;再叠加测试数据中部分较长的富文本招聘通告,30 条记录累加起来非常容易击穿单次输出配额。

输出一旦达到上限,大模型便不会补全花括号,也不会闭合数组,导致后端的反序列化在解析残损字符串时抛出异常。

02、怎么让大模型“接着说”?

既然一次返回不完,就让大模型分多次返回。

每次返回的内容先存起来,然后告诉大模型“你之前的结果不完整,继续返回剩余的内容”,它就会接着上次截断的地方继续输出。

这依赖一个前提,大模型需要“记住”之前说了什么。

Spring AI 的 ChatMemory 正好提供了这个能力。它会把每一轮对话的消息都保存下来,下一轮对话时把历史消息一起发给模型,模型就有了上下文。

初始化对话上下文

求职派的实现是这样的。

先创建一个对话记忆实例,设定最多保留 10 轮消息,然后注入系统提示词,告诉大模型它是一个“专业的数据挖掘者”。

Java

ChatMemory chatMemory = MessageWindowChatMemory
    .builder().maxMessages(10).build();
String chatId = RandomUtil.randomString(6);
SystemMessage systemMessage = new SystemMessage(GATHER_SYSTEM_PROMPT);
chatMemory.add(chatId, systemMessage);

第一轮对话时,用户消息的内容就是需要采集的原始数据,再拼接上结构化输出的格式说明。

图片.png

这里用了 Spring AI 的提示词模板做文本替换,格式说明由结构化输出转换器自动生成,它会根据目标 Java record 的字段定义生成对应的 JSON Schema,让大模型知道该返回什么结构的数据。

从第二轮开始,用户消息就变成了一句固定的续传指令。

Java

if (cnt == 0) {
    msg = UserMessage.builder()
        .text(new PromptTemplate("{text}.{format}")
            .render(Map.of("text", text, "format", converter.getFormat())))
        .build();
} else {
    msg = new UserMessage("你之前返回的结果不完整,继续返回剩余的内容");
}

什么时候停下来?

多轮对话的难点不在“怎么续传”,而在“什么时候停”。

求职派设置了两个退出条件。第一个是最多对话 10 轮,避免死循环。第二个是启发式的,如果大模型返回的内容以 ```````````结尾(Markdown

还有一种特殊情况,大模型没有接着上次截断的地方继续,而是重新返回了一份完整的数据。这时候继续对话下去也是在重复解析,求职派检测到这种情况后会主动退出循环。

图片.png

过程中还注册了一个网页抓取工具(Function Calling),让大模型在遇到 HTTP 链接类型的输入时,能够主动调用工具去抓取网页中的表格内容。这个工具内部用 Jsoup 解析 HTML,提取 `` 元素的内容。

多轮对话解决的是“模型返回不完”的问题,但它引出了一个更棘手的问题。每次返回的 JSON 都是残缺的,怎么把它们拼成完整的数据?

03、碎片化的 JSON 怎么拼回完整数据?

大模型每次返回的内容格式是不可控的。实测下来,至少会出现以下四种情况。

  • 以 ```json开头,包含完整的

  • 没有 Markdown 标记,直接就是 JSON 内容

  • 从某个字段的值中间截断,比如 panyName": "昂瑞微" ( com 被截掉了)

  • 同一条数据在上一轮的末尾和这一轮的开头各出现了一半

求职派的处理策略是 逐条提取。不去拼完整的 JSON 数组,而是每次返回的内容,先把其中能独立解析的 JSON 对象(一条岗位数据)提取出来,剩余解析不了的碎片保留到下一轮继续拼接。

碎片提取算法

核心算法是括号计数法。遍历字符串的每个字符,遇到 { 就加一,遇到 } 就减一,当计数器回到 0 的时候,说明找到了一个完整的 JSON 对象。

图片.png

这里有一个细节需要处理。JSON 的字符串值里也可能包含 { 和 } 字符。算法通过追踪引号状态(是否在字符串内部)来避免误判,遇到转义引号 \" 则跳过。

每提取出一个完整的 JSON 对象,就从 StringBuilder 中删除已提取的部分,剩余的碎片留给下一轮拼接。末尾不完整的属性(比如 "companyName": "昂瑞 这种截断到一半的字段)会被正则表达式清理掉,避免影响后续拼接。

格式预处理

在调用括号计数法之前,还需要做一步预处理,清理大模型返回内容中的 Markdown 标记,并处理截断点的衔接。

图片.png

预处理逻辑会检查上一轮剩余碎片的末尾和这一轮内容的开头,判断它们能否直接拼接。如果上一轮末尾刚好停在一个字段值的引号处,而这一轮以 { 开头(说明大模型跳过了截断点,重新开始了一条数据),就自动补上 }, 来闭合上一条不完整的数据。

这套碎片提取机制确实能把多轮返回的 JSON 碎片拼回完整数据,但拼接逻辑容易出 corner case。

04、能不能从源头就避免截断?

可以,前提是输入数据能够按行拆分。

对于 CSV 和 Excel 这两种文件类型,数据天然是行结构的。每一行就是一条完整的招聘信息,从任意行处截断都不会破坏单条数据的完整性。

求职派的做法是在读取文件时就进行预拆分,然后每批 6 条数据加上标题行一起发给大模型。6 条数据量不大,大模型一次返回就能覆盖完整的 JSON 结果,根本不会触发截断。

CSV 的拆分

CSV 文件按换行符切分,第一行作为表头,其余每一行都是一条数据。

图片.png

Excel 的拆分

Excel 的处理借助了 FastExcel 的 ReadListener 回调机制,在读取过程中就把表头和每一行数据分别提取出来。

图片.png

分批调用

拿到拆分后的数据,就可以按每批 6 条的粒度循环调用大模型了。

图片.png

两种方案怎么选?

输入预拆分的实现更简单,效果也更稳定。因为单批数据量小,大模型一次就能返回完整的结果,不需要处理碎片拼接这些麻烦事。

图片.png

但它有一个硬性前提,输入数据必须是可以按行拆分的结构化数据(CSV、Excel)。对于纯文本、图片、网页链接这类输入,没有明确的分割边界,强行拆分反而会破坏数据的完整性。

所以求职派的策略是 CSV 和 Excel 走输入预拆分,其他类型走多轮对话续传。两种方案不是非此即彼的替代关系,而是根据输入类型自动选择的互补方案。

需要测试验证的小伙伴,可以使用 resources/data 目录下提供的测试数据文件进行验证。

ending

【大模型的输出上限是强制约束,不会因为你的 JSON 写到一半就网开一面。求职派的做法说不上多精巧,多轮对话续传加输入预拆分,一个兜底一个治本,能拆就拆,拆不了就让模型接着返回。】

我们下期见。

如何把求职派写到简历上?

项目名称:求职派(JobClaw)

项目简介:OpenClaw 风格的多 Agent 求职系统,支持微信/钉钉/飞书等 IM 渠道接入,消息经意图分类后路由到可插拔的业务 Agent,内置岗位数据采集、岗位检索、岗位推荐等核心能力。

技术栈:Java 21、Spring Boot 4.0、Spring AI 2.0、LangGraph4J、JPA/Hibernate、FastExcel、Jsoup

核心职责:

  • 基于 Spring AI 的 ChatMemory 实现多轮对话续传机制,解决大模型单次输出超限导致的 JSON 截断问题,最大支持 10 轮自动续传

  • 设计并实现括号计数法 JSON 碎片提取算法,支持从不完整的大模型返回中逐条提取有效 JSON 对象,兼容 Markdown 标记清理、截断点自动补全、不完整属性清理等边界情况

  • 实现 CSV/Excel 文件的输入预拆分策略,每批 6 条数据分批调用大模型,从源头规避截断问题,相比多轮对话方案减少了 60% 以上的模型调用次数

  • 基于 Spring AI 的 BeanOutputConverter 实现结构化输出,自动生成包含 15 个字段的 JSON Schema 格式说明,引导大模型返回符合目标 record 结构的数据

  • 通过 Function Calling 注册网页抓取工具,使用 Jsoup 解析 HTML 表格元素,让大模型在遇到 HTTP 链接输入时能够自动获取网页中的招聘数据