第 09 篇:实践一 —— 文本摘要(Map-Reduce 分块)

第 09 篇:实践一 ------ 文本摘要(Map-Reduce 分块)

系列:《Java 大模型应用开发入门》第 09 篇。

源码定位:

  • 服务:com.example.llm.service.SummaryService
  • DTO:com.example.llm.dto.biz.SummaryDtos
  • 提示词:com.example.llm.prompt.Prompts#SUMMARY_SYSTEM / #mapSummarySystem
  • 接口:POST /ai/summarize
  • 配置:ai.summary.max-input-chars=4000ai.summary.chunk-size=1200

一、背景

文本摘要看起来是最简单的 AI 场景:一段文本进去,一段摘要出来,一个提示词的事。

但一放到真实业务里,第一个撞上的墙就是:文章比上下文窗口还长。

一份 3 万字的行业研报、一篇 5 万字的会议纪要、一份 8 万字的合同 ------ 直接塞进 messages 里,要么超过模型的上下文窗口被截断,要么超出 max-tokens 报错,要么即使塞进去了,你也在为几十万 Token 付钱。

这篇解决的就是这件事:用 Map-Reduce 分块,把「无限长」的输入变成「有限长」的调用。


二、目标

  1. 短文本走一次调用,长文本自动切换到分块 Map-Reduce;
  2. 切块必须按段落边界切,不能按固定字符数硬切;
  3. 摘要结果结构化(summary + keyPoints + keywords),并告诉调用方有没有走分块、分了几块;
  4. 大模型不可用时能降级,接口永远有可用返回。

三、前置

  • 第 05 篇的结构化输出(AiInvoker.chatJson(..., Class))已就绪;
  • 第 08 篇的容错与降级已就绪。

四、核心概念:Map-Reduce 分块

复制代码
   原文(可能是 5 万字)
        │
        │  按段落边界切块
        ▼
   [块1] [块2] [块3] ... [块N]
     │     │     │         │
     ▼     ▼     ▼         ▼        (Map:每块单独摘要,彼此独立)
 [摘要1][摘要2][摘要3] ... [摘要N]
     └─────┴─────┴─────────┘
               │
               ▼                    (Reduce:把 N 段摘要合成一篇)
           [最终摘要]

有两个关键决策要说清楚。

为什么一定要按段落切

假设原文是「......本次发布重构了大模型客户端......」,你按 1200 字符硬切,正好切在「重构」中间:

复制代码
块 A:......本次发布重
块 B:构了大模型客户端......

模型拿到块 A,看不到完整信息,但它不会说「我不知道」,它会编。这是大模型最危险的行为特性 ------ 有强烈的编造内容以「补全语义」的倾向。

所以 splitByParagraph 的分块单位是段落,只有当单个段落本身就超过 chunk-size 时,才退而求其次按字符切。

为什么 Map 阶段的提示词要额外加一句约束

java 复制代码
public static String mapSummarySystem(AiProperties properties) {
    return SUMMARY_SYSTEM + "\n注意:这是长文的一个片段,请只总结本片段,不要补充片段之外的信息。";
}

因为 Map 阶段每一块都是孤立的,模型看不到上下文。如果不加这句,它会脑补出这一块之外的内容 ------ 比如看到「如上所述,第 3 点的结论是......」,它会自己编一个第 3 点。

加上这句,是明确告诉模型:你的世界只有这一段。


五、代码实操

入口:按长度分流

java 复制代码
public SummaryDtos.Result summarize(SummaryDtos.Request request) {
    long start = System.currentTimeMillis();
    int[] tokens = new int[2];
    try {
        String text = request.getText();
        int maxInput = aiProperties.getSummary().getMaxInputChars();
        SummaryDtos.Result result;
        if (text.length() <= maxInput) {
            result = summarizeOnce(text, request.getMaxLength(), tokens);
            result.setChunked(false);
            result.setChunkCount(1);
        } else {
            result = summarizeChunked(text, maxInput, request.getMaxLength(), tokens);
        }
        result.setDegraded(false);
        logService.record(ENDPOINT, aiProperties.getModel(),
                usageOf(tokens), System.currentTimeMillis() - start, true, null, false);
        return result;
    } catch (AiException e) {
        log.warn("摘要失败,返回降级结果: {}", e.getMessage());
        logService.record(ENDPOINT, aiProperties.getModel(), usageOf(tokens),
                System.currentTimeMillis() - start, false, e.getErrorCode(), true);
        return fallback(request.getText(), e);
    }
}

注意 int[] tokens 这个写法:用一个一维数组在多次调用间累加 Token 数。因为 Map 阶段可能调用 5 次、Reduce 再 1 次,这些调用的 Token 都要计入这一次业务请求的成本,否则 /ai/stats 里的成本统计就是错的。

短文本:一次调用

java 复制代码
private SummaryDtos.Result summarizeOnce(String text, Integer maxLength, int[] tokens) {
    String user = Prompts.wrap("【待摘要文本】", text)
            + (maxLength == null ? "" : "\n摘要长度控制在 " + maxLength + " 字以内。");
    AiInvoker.AiResult<SummaryDtos.Result> result = aiInvoker.chatJson(
            List.of(Message.system(Prompts.SUMMARY_SYSTEM), Message.user(user)),
            0.3, SummaryDtos.Result.class);
    accumulate(tokens, result);
    return normalize(result.data());
}

temperature=0.3,因为摘要要稳定,不能用 0.7 那种偏创意的值。长度约束写在 user 消息里,跟着数据走,而不是写在 system 里,这样可以对同一次业务做不同长度的摘要。

长文本:Map-Reduce

java 复制代码
private SummaryDtos.Result summarizeChunked(String text, int maxInput, Integer maxLength, int[] tokens) {
    List<String> chunks = splitByParagraph(text, aiProperties.getSummary().getChunkSize());
    log.info("summary.chunked totalChars={} chunks={}", text.length(), chunks.size());

    // Map 阶段
    List<String> partialSummaries = new ArrayList<>(chunks.size());
    for (String chunk : chunks) {
        AiInvoker.AiResult<SummaryDtos.Result> part = aiInvoker.chatJson(
                List.of(Message.system(Prompts.mapSummarySystem(aiProperties)),
                        Message.user(Prompts.wrap("【待摘要文本】", chunk))),
                0.3, SummaryDtos.Result.class);
        accumulate(tokens, part);
        partialSummaries.add(part.data().getSummary());
    }

    // Reduce 阶段
    String merged = String.join("\n", partialSummaries);
    if (merged.length() > maxInput) {
        merged = merged.substring(0, maxInput);   // 二次保护:合并后仍超长就截断
    }
    String user = Prompts.wrap("【待摘要文本】", merged)
            + "\n这是长文各段落的摘要,请合成一篇完整摘要。"
            + (maxLength == null ? "" : "\n最终摘要控制在 " + maxLength + " 字以内。");
    AiInvoker.AiResult<SummaryDtos.Result> reduce = aiInvoker.chatJson(
            List.of(Message.system(Prompts.SUMMARY_SYSTEM), Message.user(user)),
            0.3, SummaryDtos.Result.class);
    accumulate(tokens, reduce);

    SummaryDtos.Result result = normalize(reduce.data());
    result.setChunked(true);
    result.setChunkCount(chunks.size());
    return result;
}

Reduce 阶段复用了 SUMMARY_SYSTEM。因为它的输入已经不是长文片段了,而是一段由摘要拼成的中等长度文本,跟短文本摘要的语义是一致的。

merged 的截断是必要的保护。5 个块各 150 字的摘要拼起来才 750 字,不超长;但如果块特别多(比如 30 块),合并后就可能超过 max-input-chars,这里必须兜一手。

按段落切块

java 复制代码
List<String> splitByParagraph(String text, int chunkSize) {
    List<String> chunks = new ArrayList<>();
    String[] paragraphs = text.split("\\n+");
    StringBuilder current = new StringBuilder();
    for (String paragraph : paragraphs) {
        // 当前累积块 + 这一段超长 → 先把当前块收起来,另起一块
        if (current.length() > 0 && current.length() + paragraph.length() > chunkSize) {
            chunks.add(current.toString());
            current.setLength(0);
        }
        // 单段本身就超长 → 只能按字符硬切(退化路径)
        if (paragraph.length() > chunkSize) {
            int cursor = 0;
            while (cursor < paragraph.length()) {
                int end = Math.min(paragraph.length(), cursor + chunkSize);
                chunks.add(paragraph.substring(cursor, end));
                cursor = end;
            }
            continue;
        }
        current.append(paragraph).append('\n');
    }
    if (current.length() > 0) {
        chunks.add(current.toString());
    }
    return chunks;
}

这段逻辑有两个易错点:

  1. current.length() > 0 这个判断不能省。否则第一个段落就会触发一次「收块」,产出一个空字符串块,空块送给模型会浪费一次调用;
  2. 超长单段的处理是 continue,不能让它继续往下走。如果超长单段已经按字符切完,不能再 appendcurrent 里,否则会把已经切出去的重复内容又加回来。

降级

java 复制代码
private SummaryDtos.Result fallback(String text, AiException e) {
    SummaryDtos.Result result = new SummaryDtos.Result();
    String plain = text.replaceAll("\\s+", " ").trim();
    String head = plain.length() <= 120 ? plain : plain.substring(0, 120);
    result.setSummary("【摘要服务暂不可用,以下为原文开头】" + head + "...");
    result.setKeyPoints(List.of("AI 摘要不可用(" + e.getErrorCode().getMessage() + "),建议稍后重试"));
    result.setKeywords(List.of(AiJsonUtils.brief(text, 6)));
    result.setChunked(text.length() > aiProperties.getSummary().getMaxInputChars());
    result.setChunkCount(0);
    result.setDegraded(true);
    return result;
}

降级的核心是结构一致:返回的还是一个 SummaryDtos.Result,只是内容退化成了「原文前 120 字加一句提示」。前端拿到后判断 degraded == true,就知道这是兜底数据,可以直接给用户一个「稍后重试」的提示,而不用崩。


六、验证

短文本(chunked=false

复制代码
$ curl -X POST http://127.0.0.1:8080/ai/summarize -d '{"text":"本次发布主要完成了三件事..."}'
HTTP 200
{
  "code": 0,
  "message": "成功",
  "traceId": "5d3907d3ca95456b",
  "data": {
    "summary": "本次发布主要完成了三件事。第一,重构了大模型客户端,把超时、重试、限流统一收口到一处。第二,新增结构化输出能力,业务可以直接拿到 Java 对象。第三,补齐了链路追踪,每个请求都有 traceId,可以按 traceId 回放整条调用链。上线后接口平均耗时从 1.8 秒降到 0.9 秒,失败率从 3.2% 降到 0.4%。",
    "keyPoints": [
      "本次发布主要完成了三件事",
      "第一,重构了大模型客户端,把超时、重试、限流统一收口到一处",
      "第二,新增结构化输出能力,业务可以直接拿到 Java 对象",
      "第三,补齐了链路追踪,每个请求都有 traceId,可以按 traceId 回放...",
      "上线后接口平均耗时从 1.8 秒降到 0.9 秒,失败率从 3.2% 降到 0...."
    ],
    "keywords": ["降到", "本次发布", "主要完成", "了三件事", "重构了大"],
    "chunked": false,
    "chunkCount": 1,
    "degraded": false
  }
}

长文本(chunked=true,输入 5166 字)

复制代码
$ curl -X POST http://127.0.0.1:8080/ai/summarize -d '{...5166 字长文...}'
HTTP 200
{
  "code": 0,
  "traceId": "4ab1b5a9261d42b2",
  "data": {
    "summary": "第1章 本章讨论大模型应用工程化的第 1 个议题。在真实项目里,超时、重试、限流三者必须协同设计:超时决定了单次调用的时间预算,重试决定了失败后的补救次数,限流决定了整体吞吐的上限。三者中任意一个缺失,都会在流量高峰时演变成雪崩。建议的做法是把三者统一收敛到一个容错执行器里,业务代码只调用一个方法,由执行器决定重试几次、退避多久、何时熔断。",
    "keyPoints": [
      "第1章 本章讨论大模型应用工程化的第 1 个议题",
      "在真实项目里,超时、重试、限流三者必须协同设计:超时决定了单次调用的时间预算,重...",
      "三者中任意一个缺失,都会在流量高峰时演变成雪崩",
      "建议的做法是把三者统一收敛到一个容错执行器里,业务代码只调用一个方法,由执行器决...",
      "第6章 本章讨论大模型应用工程化的第 6 个议题"
    ],
    "keywords": ["决定", "重试", "三者", "执行器", "超时"],
    "chunked": true,
    "chunkCount": 5,
    "degraded": false
  }
}

5166 字切成 5 块,6 次调用(5 次 Map 加 1 次 Reduce),产出一篇全局摘要。

这里能清楚看到 mock 服务的语义局限:keywords 出现「了三件事」「重构了大」这种明显被硬切出来的碎片,summary 也偏向复述第 1 块。

原因很简单,mock 是规则引擎,不是真模型。

这恰好说明一个原则:教学环境可以用 mock 验证「链路对不对」,但绝不能用来「评估效果好不好」。评估摘要质量必须换真模型。

看日志确认分块

复制代码
summary.chunked totalChars=5166 chunks=5

演示页实拍

短文本摘要(chunked=false):

长文本触发 Map-Reduce(chunked=true, chunkCount=5):


七、常见坑

坑 1:按固定字符数硬切。

硬切会把一句话劈成两半,模型会据此编造。切块的第一原则是语义完整,不是长度均匀。

坑 2:Map 阶段忘了传「只总结本片段」的约束。

模型会跨块脑补,最终摘要里出现原文根本没有的内容。

坑 3:Token 统计只算了 Reduce,忘了 Map。

Map 阶段可能调用 N 次,这 N 次的 Token 都要累加。否则你看到的成本会远远低于真实成本,做预算时会严重误判。

坑 4:合并后的文本不做超长保护。

块数多的时候,merged 可能又超过 max-input-chars,Reduce 阶段直接报「输入过长」。

坑 5:把「摘要服务不可用」当成 500 抛给前端。

摘要是一个可以在降级下继续服务的能力。返回原文开头加 degraded=true,用户至少能看到点东西;直接抛异常,用户看到的就是白屏。


八、小结与下一篇

Map-Reduce 分块是处理超长输入的通用模式,不止用于摘要。长文问答、长文分类、长文翻译,都可以套这个骨架。

下一篇做内容分类:怎么把标签约束住(不让模型自创标签)、怎么用置信度决定是否需要人工介入,以及分类场景特有的兜底策略。

相关推荐
Behaviour1 小时前
豆包手机助手消费者版发布:首款量产 AI 智能体手机 9 月 16 日开售
人工智能·语言模型·aigc·ai编程
prog_61031 小时前
【笔记】用agent手搓agent(一)
人工智能·llm·大语言模型·agent
咖啡八杯1 小时前
Controller 基类封装:BaseController 的模板方法设计
java·架构·设计
vx_BS813301 小时前
【项目编号:project49759】Spring Boot 宠物综合服务平台:商城、预约、咨询、活动与健康档案的一体化实现
java·spring boot·eclipse·tomcat·intellij-idea
三声三视1 小时前
一个卡片入场动画我返工 4 次:tri-lottie 规格单落地到 ArkTS 的踩坑记录
人工智能·ai·skillhub·tri-skills·tri-lottie
她的男孩1 小时前
加了 @Idempotent 还是重复扣款了 3 笔:扒完 1279 行幂等 Starter,我挖出 5 个隐蔽的坑
java·后端·架构
南京兴帝文化传媒有限公司1 小时前
AI大模型如何抓取和推荐无锡本地商户?GEO技术链路与POI权重算法拆解
大数据·人工智能·生活·geo 优化·ai搜索获客·无锡geo优化·长三角geo优化
是枚小菜鸡儿吖1 小时前
Windows 和 iPhone 怎么互传文件?用 PairDrop 搭一个自己的浏览器传输入口
服务器·人工智能·大模型
小刘快学习1 小时前
高并发下的优雅降级:企业AI网关的流量整形与过载保护
人工智能