Xử lý văn bản dài trong hệ thống AI Agent: Chiến lược phân tầng và tối ưu hóa hiệu suất

Đây là bài chia sẻ kinh nghiệm thực tiễn từ dự án taskFlow, tập trung vào cách xử lý thông tin đầu ra dài từ các công cụ (tool) trong kiến trúc Agent dựa trên mô hình ngôn ngữ lớn (LLM). Giải pháp được xây dựng nhằm cân bằng giữa độ chính xác, chi phí tính toán và khả năng mở rộng — đặc biệt khi làm việc với mã nguồn, tài liệu kỹ thuật hoặc phản hồi HTTP khổng lồ.

Kiến trúc tổng quan

Dưới đây là luồng xử lý chính khi một công cụ trả về nội dung vượt ngưỡng cho phép:

┌───────────────────────────────────────────────────────────────────────────────┐
│                      Kết quả công cụ (đọc file, gọi API, chạy lệnh shell...)    │
└─────────────────────────────────────────┬───────────────────────────────────────┘
                                          │
                                          ▼
┌───────────────────────────────────────────────────────────────────────────────┐
│  core::handle_tool_output                                                       │
│  ├─ Công cụ yêu cầu giữ nguyên nội dung? (vd: read_file) → chỉ cắt biên, không tóm tắt │
│  └─ Ngược lại → pipeline::process_inline (xử lý đồng bộ nhanh)                    │
└─────────────────────────────────────────┬───────────────────────────────────────┘
                                          │
                    ┌─────────────────────┼─────────────────────┐
                    │ ≤ 11.5k ký tự       │ 11.5k–28.5k ký tự     │ > 28.5k ký tự
                    ▼                     ▼                     ▼
              [trả trực tiếp]      [cắt đầu/cuối + ghi chú]   [text_analyze::condense_large_payload]
                                                                 │
                                                                 ▼
                                              ┌──────────────────────────────────────┐
                                              │ 1. Phân đoạn an toàn theo UTF-8         │
                                              │ 2. Lọc đoạn theo trọng số chiến lược     │
                                              │ 3. Giai đoạn Map: tóm tắt từng đoạn     │
                                              │ 4. Giai đoạn Reduce: hợp nhất kết quả   │
                                              └──────────────────────────────────────┘

Phân công chức năng các module

ModuleTệp tinMô tả nhiệm vụ
Điểm vào chínhcore/mod.rsHàm handle_tool_output quyết định luồng xử lý dựa trên loại công cụ và độ dài nội dung
Xử lý đồng bộpipeline/inline.rsThực hiện phân nhánh ba mức: trả thẳng, cắt biên, hoặc chuyển sang xử lý bất đồng bộ
Tóm tắt văn bản dàitext_analyze/mod.rsTriển khai logic phân đoạn, chọn đoạn, MapReduce
Đánh giá đoạntext_analyze/scoring.rsTính điểm từng đoạn dựa trên vị trí, tín hiệu ngữ cảnh và mật độ thực thể

Cấu trúc thư mục mã nguồn

taskflow/src/core/
├── mod.rs                 # hàm handle_tool_output – cổng vào chính
├── types.rs               # safe_slice, utf8_chunker, cấu hình môi trường
├── pipeline/
│   └── inline.rs          # process_inline + fallback_truncate
└── text_analyze/
    ├── mod.rs             # condense_large_payload, select_segments, map_reduce_flow
    └── scoring.rs         # score_segment (vị trí + ngữ cảnh + thực thể)

Tại sao văn bản dài gây khó khăn?

Khi Agent tích hợp nhiều công cụ, dữ liệu đầu ra thường vượt xa giới hạn ngữ cảnh của LLM — dẫn đến lỗi token overflow hoặc suy giảm chất lượng suy luận do nhiễu thông tin. Các nguồn phổ biến gồm:

  • read_file với hàng chục nghìn dòng mã nguồn
  • http_fetch trả về HTML thô chứa script, CSS và nội dung phụ
  • shell_exec xuất log dài kèm stack trace

Mục tiêu thiết kế không phải là "giữ tất cả", mà là "giữ đúng phần cần thiết" — đảm bảo LLM vẫn có thể thực hiện hành động tiếp theo một cách chính xác.

Chiến lược xử lý: Phân tầng + Lọc thông minh

3 mức phân nhánh theo độ dài

Độ dài nội dungHành độngGiải thích
≤ 11.500 ký tựTrả nguyên bảnPhù hợp với đa số phản hồi công cụ (log ngắn, JSON nhỏ, trang web rút gọn)
11.500–28.500 ký tựCắt hai đầu + ghi chú rõ ràngGiữ phần mở đầu (mục đích) và kết thúc (kết luận), thêm dòng "[... bị cắt — tổng cộng X ký tự ...]"
> 28.500 ký tựKích hoạt quy trình tóm tắt bất đồng bộPhân đoạn → lọc → Map (tóm tắt từng phần) → Reduce (hợp nhất)

Ngưỡng 28.500 ký tự (TASKFLOW_SUMMARY_THRESHOLD) được chọn sau thử nghiệm thực tế: ở mức 14.000, nhiều tài liệu HTML có cấu trúc DOM phức tạp bị tóm tắt quá mức — khiến Agent mất khả năng trích xuất selector CSS hoặc xác định thẻ mục tiêu.

Code mẫu – Luồng điều khiển và xử lý sơ bộ

// core/mod.rs: Xử lý bất đồng bộ cho nội dung dài
pub async fn handle_tool_output(
    client: &Arc<LlmClient>,
    model: &str,
    tool_id: &str,
    payload: String,
) -> String {
    match pipeline::inline::process_inline(&payload) {
        Some(simplified) => simplified,
        None => {
            if is_content_essential(tool_id) {
                // Với read_file, fetch_raw_html: KHÔNG tóm tắt — chỉ cắt biên
                pipeline::inline::fallback_truncate(&payload)
            } else {
                // Kích hoạt pipeline đầy đủ
                text_analyze::condense_large_payload(client, model, &payload).await
            }
        }
    }
}

// pipeline/inline.rs: Xử lý đồng bộ
pub fn process_inline(content: &str) -> Option<String> {
    let max_inline = types::get_max_inline_chars();        // mặc định: 11500
    let summary_trigger = types::get_summary_threshold(); // mặc định: 28500

    if content.len() <= max_inline {
        return Some(content.to_owned());
    }
    if content.len() > summary_trigger {
        return None;
    }

    // Cắt và gắn ghi chú
    let head = safe_slice(content, 0, 3500);
    let tail = safe_slice_from_end(content, 2500);
    format!(
        "{}\n\n[... nội dung bị cắt — tổng cộng {} ký tự ...]\n{}",
        head, content.len(), tail
    )
}

Ba chiến lược lựa chọn đoạn (segment selection)

Sau khi chia nội dung thành các đoạn, không phải đoạn nào cũng xứng đáng đưa vào giai đoạn tóm tắt. Hệ thống hỗ trợ ba cơ chế qua biến môi trường TASKFLOW_SEGMENT_STRATEGY:

1. Chỉ lấy đầu – cuối (head_tail_only, mặc định)

  • Cách thức: Chọn N đoạn đầu và M đoạn cuối, bỏ toàn bộ phần giữa
  • Phù hợp: Báo cáo, tài liệu có phần mở đầu/kết luận rõ ràng, hoặc khi ưu tiên tốc độ và chi phí
  • Ưu điểm: Không cần gọi LLM để đánh giá — cực kỳ nhanh và ổn định

2. Lọc theo điểm số (head_tail_scored)

  • Cách thức: Mỗi đoạn được chấm điểm theo ba yếu tố:
    • Vị trí (55%): Đoạn nằm trong 15% đầu hoặc 15% cuối được cộng điểm cao
    • Ngữ cảnh (25%): Phát hiện từ khóa như "kết luận", "tóm lại", "kết quả chính", "phát hiện mới"
    • Thực thể (20%): Mật độ số, tên riêng, mã lỗi hoặc định danh kỹ thuật cao hơn trung bình
  • Phù hợp: Văn bản có thông tin trọng tâm phân tán (ví dụ: phần "Thí nghiệm" nằm ở giữa báo cáo khoa học)

3. Toàn bộ đoạn đều được xử lý (mapreduce_all)

  • Cách thức: Tất cả đoạn đều đi qua giai đoạn Map — không lọc — rồi hợp nhất ở Reduce
  • Phù hợp: Khi yêu cầu độ bao phủ cao (ví dụ: phân tích hợp đồng pháp lý, kiểm tra tuân thủ)
  • Lưu ý: Nên kết hợp với TASKFLOW_MAP_MODEL để dùng mô hình nhẹ cho Map, giữ mô hình mạnh cho Reduce

Code mẫu – Cơ chế chọn đoạn và chấm điểm

// text_analyze/mod.rs: Chuyển đổi chiến lược thành danh sách đoạn
pub fn select_segments(
    content: &str,
    strategy: SegmentStrategy,
) -> Vec<&str> {
    let segments = utf8_chunker::split_by_size(content, types::get_chunk_size()); // 5800 ký tự
    match strategy {
        SegmentStrategy::HeadTailOnly => {
            let head_n = types::get_head_count();
            let tail_n = types::get_tail_count();
            let mut selected = segments[..head_n.min(segments.len())].to_vec();
            let tail_start = segments.len().saturating_sub(tail_n);
            selected.extend_from_slice(&segments[tail_start..]);
            selected
        }
        SegmentStrategy::HeadTailScored => {
            let scores: Vec<(f64, &str)> = segments
                .iter()
                .map(|&s| (scoring::score_segment(s, *segments.iter().position(|x| x == s).unwrap_or(0), segments.len()), s))
                .collect();
            let top_k = (segments.len() as f64 * types::get_top_ratio()).round() as usize;
            let mut sorted = scores.into_iter().rev().take(top_k).collect::();
            sorted.sort_by_key(|(_, s)| segments.iter().position(|x| *x == *s).unwrap_or(0));
            sorted.into_iter().map(|(_, s)| *s).collect()
        }
        SegmentStrategy::MapReduceAll => segments,
    }
}

// text_analyze/scoring.rs: Hàm chấm điểm không phụ thuộc NLP bên ngoài
pub fn score_segment(segment: &str, idx: usize, total: usize) -> f64 {
    let pos = position_weight(idx, total);           // 0.15 đầu / cuối → 1.0; còn lại → 0.12
    let ctx = context_keyword_score(segment);         // phát hiện từ khóa ngữ cảnh
    let ent = entity_density_score(segment);          // đếm số, tên riêng, mã định danh
    0.55 * pos + 0.25 * ctx + 0.20 * ent
}

Giai đoạn MapReduce và tối ưu chi phí mô hình

Trong giai đoạn Map, mỗi đoạn được gửi độc lập tới LLM để tạo bản tóm tắt ngắn (~400 ký tự), tập trung vào sự kiện, con số và kết luận thực tế. Giai đoạn Reduce nhận toàn bộ bản tóm tắt con, sau đó sinh ra một bản tổng hợp duy nhất (~2500 ký tự).

Để giảm chi phí, hệ thống hỗ trợ cấu hình riêng biệt:

  • TASKFLOW_MAP_MODEL: Nếu được đặt (vd: qwen2.5-7b-instruct hoặc phi-4-mini), sẽ dùng cho toàn bộ giai đoạn Map
  • Nếu không đặt, Map và Reduce đều dùng mô hình gốc

Việc tách biệt giúp giảm tới 60–75% chi phí tính toán trong các tác vụ xử lý văn bản dài.

Code mẫu – Quy trình MapReduce hoàn chỉnh

pub async fn condense_large_payload(
    client: &Arc<LlmClient>,
    base_model: &str,
    content: &str,
) -> String {
    let segments = select_segments(content, types::get_strategy());
    let map_model = types::get_map_model(base_model);

    // Giai đoạn Map
    let mut summaries = Vec::new();
    for segment in &segments {
        let summary = summarize_segment(client, &map_model, segment).await;
        if let Ok(s) = summary {
            summaries.push(s);
        }
    }

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

    // Giai đoạn Reduce
    if combined.len() <= types::get_max_reduce_input() {
        return combined;
    }

    match merge_summaries(client, base_model, &combined).await {
        Ok(final_summary) => final_summary,
        Err(_) => safe_slice(&combined, 0, types::get_max_reduce_input()),
    }
}

An toàn UTF-8 trong phân đoạn

Việc chia đoạn theo byte thuần túy dễ gây lỗi giải mã nếu cắt giữa chuỗi Unicode đa byte (đặc biệt với tiếng Việt, emoji hoặc ký tự CJK). Hàm utf8_chunker::split_by_size đảm bảo mọi ranh giới đoạn đều nằm trên ranh giới ký tự hợp lệ:

  • Khi vị trí cắt đề xuất không phải ranh giới ký tự, thuật toán tự động lui về ký tự gần nhất phía trước
  • Nếu không thể lui (do gần đầu chuỗi), tiến về phía sau cho đến khi tìm thấy ranh giới
  • Hỗ trợ đầy đủ tiếng Việt, tiếng Trung, biểu tượng cảm xúc và tổ hợp ký tự Unicode

Code mẫu – Phân đoạn an toàn UTF-8

// types.rs: Các hàm hỗ trợ an toàn
pub fn safe_slice(s: &str, start: usize, len: usize) -> &str {
    let end = (start + len).min(s.len());
    let mut safe_end = end;
    while safe_end > start && !s.is_char_boundary(safe_end) {
        safe_end -= 1;
    }
    &s[start..safe_end]
}

pub fn safe_slice_from_end(s: &str, len: usize) -> &str {
    let end = s.len();
    let start = if len >= end { 0 } else { end - len };
    let mut safe_start = start;
    while safe_start < end && !s.is_char_boundary(safe_start) {
        safe_start += 1;
    }
    &s[safe_start..end]
}

// utf8_chunker.rs: Chia đoạn theo kích thước an toàn
pub fn split_by_size(s: &str, size: usize) -> Vec<&str> {
    let mut result = Vec::new();
    let mut cursor = 0;
    while cursor < s.len() {
        let target = (cursor + size).min(s.len());
        let mut safe_end = target;
        while safe_end > cursor && !s.is_char_boundary(safe_end) {
            safe_end -= 1;
        }
        if safe_end == cursor {
            safe_end = cursor + 1;
            while safe_end < s.len() && !s.is_char_boundary(safe_end) {
                safe_end += 1;
            }
        }
        result.push(&s[cursor..safe_end]);
        cursor = safe_end;
    }
    result
}

Các tham số cấu hình runtime

Biến môi trườngMặc địnhMô tả
TASKFLOW_SEGMENT_STRATEGYhead_tail_onlyChiến lược chọn đoạn: head_tail_only, head_tail_scored, mapreduce_all
TASKFLOW_CHUNK_SIZE5800Kích thước mỗi đoạn (≈1.4k tokens)
TASKFLOW_HEAD_COUNT2Số đoạn đầu được giữ lại
TASKFLOW_TAIL_COUNT2Số đoạn cuối được giữ lại
TASKFLOW_TOP_RATIO0.45Tỷ lệ đoạn được chọn khi dùng head_tail_scored
TASKFLOW_MAP_MODEL(trống)Mô hình dùng riêng cho giai đoạn Map
TASKFLOW_SUMMARY_THRESHOLD28500Ngưỡng kích hoạt tóm tắt đầy đủ
TASKFLOW_MAX_INLINE_CHARS11500Ngưỡng tối đa để trả nguyên bản

Tổng kết thiết kế

  • Ưu tiên chi phí: Chiến lược mặc định head_tail_only đảm bảo độ trễ thấp và chi phí ổn định
  • Bảo toàn ngữ nghĩa: Các công cụ yêu cầu giữ nguyên nội dung (như đọc file mã nguồn) luôn được xử lý bằng cơ chế cắt biên — không tóm tắt
  • Ngưỡng thực tế: Các giá trị mặc định được điều chỉnh dựa trên phân tích thống kê dữ liệu thực tế từ hàng nghìn lần gọi công cụ
  • Không phụ thuộc bên ngoài: Việc chấm điểm đoạn hoàn toàn dựa trên quy tắc nhẹ — không cần mô hình NLP riêng hay thư viện phân tích ngôn ngữ

Thẻ: Rust AI-Agent LLM text-processing MapReduce

Đăng vào ngày 14 tháng 6 lúc 23:22