本文分享 skillLite 项目在 AI Agent 工具调用场景中,对长文本处理的设计思考与实践。日常踩坑记录,希望对做 LLM 应用的同学有点启发。


架构总览

在展开细节之前,先看整体调用链与模块关系:

┌─────────────────────────────────────────────────────────────────────────────────┐
│                        工具结果(read_file / http_request / run_command 等)       │
└─────────────────────────────────────────┬───────────────────────────────────────┘
                                          │
                                          ▼
┌─────────────────────────────────────────────────────────────────────────────────┐
│  agent_loop::process_result_content                                              │
│  ├─ 是否 content-preserving 工具?(read_file) → 仅截断,不总结                      │
│  └─ 否则 → extensions::process_tool_result_content (sync 快速路径)                 │
└─────────────────────────────────────────┬───────────────────────────────────────┘
                                          │
                    ┌─────────────────────┼─────────────────────┐
                    │ len≤12000          │ 12000<len≤30000     │ len>30000
                    ▼                     ▼                     ▼
              [直接返回]            [简单截断+提示]      [long_text::summarize_long_content]
                                                                 │
                                                                 ▼
                                              ┌──────────────────────────────────┐
                                              │ 1. chunk_str 分块                  │
                                              │ 2. select_chunks 按策略选块        │
                                              │ 3. Map: 每块 LLM 总结              │
                                              │ 4. Reduce: 合并摘要               │
                                              └──────────────────────────────────┘

模块职责

模块 文件 职责
入口调度 agent_loop.rs process_result_content 判断工具类型与长度,决定走 sync 还是 async
同步处理 extensions/builtin.rs process_tool_result_content 三段式分流;process_tool_result_content_fallback 首尾截断
长文本总结 long_text/mod.rs 分块、选块、MapReduce
块打分 long_text/filter.rs Position + Discourse + Entity 轻量规则

源码目录结构

skilllite/src/agent/
├── agent_loop.rs           # process_result_content 入口
├── types.rs                # chunk_str, safe_truncate, 环境变量解析
├── extensions/
│   └── builtin.rs          # process_tool_result_content (sync)
└── long_text/
    ├── mod.rs              # summarize_long_content, select_chunks, Map/Reduce
    └── filter.rs           # score_chunk (Position + Discourse + Entity)

提示:CSDN 若无法渲染 Mermaid,可复制到 Mermaid Live Editor 导出为 PNG 插入文中。


一、为什么长文本是「老大难」?

做 Agent 的都知道:工具一多,返回内容一长,模型上下文就炸。典型的「长文本」来源包括:

  • read_file 读了个几万行的源码
  • http_request 拉了一整页 HTML
  • run_command 输出了冗长的 log

如果一股脑全塞给 LLM,轻则超 token 限导致请求失败,重则模型注意力被分散,回答质量明显下降。

所以我们需要一套策略:在不丢失关键信息的前提下,把长文本压到模型能吃得下的规模


二、核心思路:分段处理 + 策略选择

skillLite 的做法可以概括成一句话:先分块,再按策略选块,最后 MapReduce 总结

2.1 三段式分流

在真正动用 LLM 之前,我们先按长度分层处理:

内容长度 处理方式 说明
≤ 12000 字符 直接返回 大多数工具结果都在这个范围内,无需折腾
12000 ~ 30000 简单截断 + 提示 保留前半段,加一句「已截断,原文共 X 字符」
> 30000 触发 LLM 总结 分块 → 选块 → Map 总结 → Reduce 合并

这个 30000 的阈值(SKILLLITE_SUMMARIZE_THRESHOLD)是刻意调高的:之前试过 15000,发现 17KB 的 HTML 页面也会被总结掉,结果模型后续要「根据网页内容做 X」时,关键 DOM 结构已经没了,任务直接失败。所以干脆把「中等长度」交给简单截断,真正超长的才上 MapReduce。

三段式分流流程图

是 read_file 等

工具返回 content

len ≤ 12000?

直接返回

len > 30000?

简单截断 + 提示

content-preserving?

首尾截断 fallback

LLM 总结 MapReduce

入口与同步处理源码agent_loop.rs + extensions/builtin.rs):

// agent_loop.rs: 异步入口,根据工具类型决定是否总结
async fn process_result_content(client, model, tool_name, content) -> String {
    match extensions::process_tool_result_content(content) {
        Some(processed) => processed,  // sync 已处理完毕
        None => {
            if CONTENT_PRESERVING_TOOLS.contains(&tool_name) {
                // read_file 等:绝不总结,只用首尾截断
                extensions::process_tool_result_content_fallback(content)
            } else {
                // 超长内容:触发 LLM 总结
                long_text::summarize_long_content(client, model, content).await
            }
        }
    }
}

// extensions/builtin.rs: 同步快速路径
pub fn process_tool_result_content(content: &str) -> Option<String> {
    let max_chars = types::get_tool_result_max_chars();      // 12000
    let summarize_threshold = types::get_summarize_threshold(); // 30000
    if content.len() <= max_chars {
        return Some(content.to_string());  // 直接返回
    }
    if content.len() > summarize_threshold {
        return None;  // 交由 async 处理
    }
    // 中间区间:简单截断
    Some(format!("{}\n\n[... 结果已截断,原文共 {} 字符 ...]", ...))
}

2.2 特殊工具:不总结、只截断

有些工具的结果必须原样保留,比如 read_file:用户可能要让模型「把这段代码复制到另一个文件」,你一总结,代码就没了。对于这类 content-preserving 工具,我们永远不做 LLM 总结,超出阈值时只用「首尾截断」:保留开头和结尾各一段,中间省略,至少能让模型知道「内容很长,这里截掉了」。


三、三种 Chunk 选择策略

分块之后,不是每块都值得送进 LLM。我们提供了三种策略,通过 SKILLLITE_LONG_TEXT_STRATEGY 配置:

3.1 首尾选择(head_tail_only,默认)

逻辑:只取前 N 块 + 后 M 块,中间全丢。

适用:引言 + 结论型文档(如论文摘要、报告)、或者优先压成本、追求速度的场景。

优点:实现简单,调用次数少,成本可控。
缺点:中间有重点的文档(如「第 3 节有核心算法」)可能漏掉关键信息。

3.2 按分抽取(head_tail_extract)

逻辑:对所有块打一个「信息量分」,按分数取 top-K,再按原文顺序排列。

打分是轻量规则,无分词、无外部 NLP 依赖:

  • Position(50%):前 20% 和 后 20% 的块得分高(1.0),中间块得分低(0.25)
  • Discourse(30%):含「总结」「结论」「关键」「实验表明」等词加分
  • Entity(20%):数字、专有名词密度高的块加分

这样中间某块如果恰好是「实验表明,关键发现……」就会自然被选中。

适用:要点分散的文档,需要保留中间关键段落时。

3.3 全量 MapReduce(mapreduce_full)

逻辑:所有块都参与 Map 总结,不做筛选;最后 Reduce 合并成一篇总摘要。

适用:需要尽量保留全文信息、又不想直接塞满上下文的场景。
成本:块越多,Map 调用越多,建议配合 SKILLLITE_MAP_MODEL 使用廉价模型做 Map,主模型只做 Reduce。

策略选择架构图

输入

head_tail_only

head_tail_extract

mapreduce_full

原文 content

chunk_str 分块

SKILLLITE_LONG_TEXT_STRATEGY

取前N + 后M块

全块打分 → top-K 按序

全量块

选中的 chunks

Map: 每块 LLM 总结

策略分发与打分源码

// long_text/mod.rs: 策略分发
fn select_chunks(...) -> (Vec<String>, String) {
    let strategy = types::get_long_text_strategy();
    let all_chunks: Vec<String> = chunk_str(content, chunk_size)
        .into_iter()
        .filter(|s| !s.trim().is_empty())
        .collect();

    match strategy {
        LongTextStrategy::HeadTailOnly => select_head_tail_only(...),
        LongTextStrategy::HeadTailExtract => select_by_score(...),  // 打分选块
        LongTextStrategy::MapReduceFull => select_all_chunks(...),
    }
}

// long_text/filter.rs: 轻量打分规则
pub fn score_chunk(chunk: &str, chunk_index: usize, total_chunks: usize) -> f64 {
    let pos = position_score(chunk_index, total_chunks);   // 前后 20% 高分
    let disc = discourse_score(chunk);  // 总结|结论|关键|实验表明...
    let ent = entity_score(chunk);      // 数字、专有名词密度
    0.5 * pos + 0.3 * disc + 0.2 * ent
}

四、MapReduce 与分层模型

Map 阶段:每块单独发一次 LLM,生成该块的简短摘要(控制在 500 字符内,强调数字、事实、关键发现)。
Reduce 阶段:把所有块的摘要拼起来,再发一次 LLM,合并成一篇总摘要(控制在 3000 字符内)。

为了压成本,我们支持 分层模型

  • SKILLLITE_MAP_MODEL 未设置:Map 和 Reduce 都用主模型
  • 设置 SKILLLITE_MAP_MODEL=qwen-plusgemini-1.5-flash:Map 用廉价模型,Reduce 仍用主模型

Map 任务量大且相对独立,对模型要求低;Reduce 负责「整合信息」,需要更好的理解能力,所以用主模型更稳。

MapReduce 流程图

Map阶段

Chunk 1

LLM 总结 ~500字

Chunk 2

LLM 总结 ~500字

Chunk N

LLM 总结 ~500字

拼接摘要

combined > 8000?

直接返回

Reduce: LLM 合并 ~3000字

返回合并结果

MapReduce 主流程源码long_text/mod.rs):

pub async fn summarize_long_content(client, model, content) -> String {
    let (chunks, truncated_note) = select_chunks(...);  // 按策略选块

    // Map: 可选 SKILLLITE_MAP_MODEL 廉价模型
    let map_model = types::get_map_model(model);
    let mut chunk_summaries = Vec::new();
    for chunk in chunks.iter() {
        let summary = summarize_single_chunk(client, &map_model, chunk).await?;
        chunk_summaries.push(summary);
    }

    let combined = chunk_summaries.join("\n\n");

    // Reduce: 若合并后仍超 8000 字,用主模型再合并一次
    if combined.len() <= max_output_chars {
        return combined;
    }
    match merge_summaries(client, model, &combined).await {
        Ok(merged) => merged,
        Err(_) => truncate_content(&combined, max_output_chars),
    }
}

五、UTF-8 安全与分块边界

分块时不能按「字节数」一刀切,否则可能砍在多字节字符中间,导致 UTF-8 解码错误。我们实现了 safe_truncatesafe_slice_from,确保切分点落在合法 UTF-8 字符边界上。

chunk_str 的逻辑大致是:每次从 start 出发,目标切到 start + chunk_size,如果该位置不是字符边界,就向前/向后微调,直到落在边界上。这样中英混合、Emoji 等都能安全处理。

UTF-8 安全分块源码types.rs):

/// 从开头截断,确保不切在多字节字符中间
pub fn safe_truncate(s: &str, max_bytes: usize) -> &str {
    if s.len() <= max_bytes { return s; }
    let mut end = max_bytes;
    while end > 0 && !s.is_char_boundary(end) {
        end -= 1;  // 回退到合法边界
    }
    &s[..end]
}

/// 分块时确保每段切在 UTF-8 字符边界
pub fn chunk_str(s: &str, chunk_size: usize) -> Vec<&str> {
    let mut chunks = Vec::new();
    let mut start = 0;
    while start < s.len() {
        let target_end = (start + chunk_size).min(s.len());
        let mut safe_end = target_end;
        while safe_end > start && !s.is_char_boundary(safe_end) {
            safe_end -= 1;  // 回退到边界
        }
        if safe_end == start && start < s.len() {
            safe_end = start + 1;
            while safe_end < s.len() && !s.is_char_boundary(safe_end) {
                safe_end += 1;  // 单字节字符边界
            }
        }
        chunks.push(&s[start..safe_end]);
        start = safe_end;
    }
    chunks
}

六、可调参数一览

环境变量 默认值 说明
SKILLLITE_LONG_TEXT_STRATEGY head_tail_only 策略:head_tail_only / head_tail_extract / mapreduce_full
SKILLLITE_CHUNK_SIZE 6000 每块字符数(约 1.5k tokens)
SKILLLITE_HEAD_CHUNKS 3 首部块数
SKILLLITE_TAIL_CHUNKS 3 尾部块数
SKILLLITE_EXTRACT_TOP_K_RATIO 0.5 抽取时 top-K = 总块数 × 该比例
SKILLLITE_MAP_MODEL (未设置) Map 阶段模型,降低 MapReduce 成本
SKILLLITE_SUMMARIZE_THRESHOLD 30000 超过此长度才触发 LLM 总结
SKILLLITE_TOOL_RESULT_MAX_CHARS 12000 工具结果直接返回的上限

七、设计取舍小结

  1. 成本 vs 质量:默认 head_tail_only 优先控制成本;对重要文档可切到 extract 或 mapreduce_full。
  2. content-preserving 工具:read_file 等不总结,避免破坏「原样传递」的语义。
  3. 阈值调优SUMMARIZE_THRESHOLD 从 15000 提到 30000,避免中等长度 HTML/代码被误杀。
  4. 轻量规则:打分不依赖分词和外部 NLP,保持实现简单、无额外依赖。

目前尚未覆盖的场景:用户直接输入的极长消息、对话历史的完整 MapReduce 等,后续会继续迭代。如果你也在做 Agent 长文本这块,欢迎交流思路~


本文基于 skillLite 0.2.0,源码见 GitHub

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐