在 Agent 场景下,我们如何优雅地处理长文本,AI Agent长文本处理实战技巧
本文介绍了skillLite项目在AI Agent工具调用场景中对长文本处理的设计思路。针对工具返回的长文本内容,系统采用分层处理策略:短文本直接返回,中等长度文本简单截断,超长文本则通过分块、选块和MapReduce总结进行压缩处理。特别地,对于read_file等需要保留原始内容的工具,仅进行首尾截断而不做总结。这种分级处理机制在保证关键信息不丢失的前提下,有效解决了模型上下文限制问题。文章还
本文分享 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拉了一整页 HTMLrun_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。
三段式分流流程图:
入口与同步处理源码(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。
策略选择架构图:
策略分发与打分源码:
// 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-plus或gemini-1.5-flash:Map 用廉价模型,Reduce 仍用主模型
Map 任务量大且相对独立,对模型要求低;Reduce 负责「整合信息」,需要更好的理解能力,所以用主模型更稳。
MapReduce 流程图:
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_truncate 和 safe_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 | 工具结果直接返回的上限 |
七、设计取舍小结
- 成本 vs 质量:默认 head_tail_only 优先控制成本;对重要文档可切到 extract 或 mapreduce_full。
- content-preserving 工具:read_file 等不总结,避免破坏「原样传递」的语义。
- 阈值调优:
SUMMARIZE_THRESHOLD从 15000 提到 30000,避免中等长度 HTML/代码被误杀。 - 轻量规则:打分不依赖分词和外部 NLP,保持实现简单、无额外依赖。
目前尚未覆盖的场景:用户直接输入的极长消息、对话历史的完整 MapReduce 等,后续会继续迭代。如果你也在做 Agent 长文本这块,欢迎交流思路~
本文基于 skillLite 0.2.0,源码见 GitHub
更多推荐



所有评论(0)