Đâ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
| Module | Tệp tin | Mô tả nhiệm vụ |
|---|---|---|
| Điểm vào chính | core/mod.rs | Hà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.rs | Thự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ài | text_analyze/mod.rs | Triển khai logic phân đoạn, chọn đoạn, MapReduce |
| Đánh giá đoạn | text_analyze/scoring.rs | Tí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_filevới hàng chục nghìn dòng mã nguồnhttp_fetchtrả về HTML thô chứa script, CSS và nội dung phụshell_execxuấ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 dung | Hành động | Giải thích |
|---|---|---|
| ≤ 11.500 ký tự | Trả nguyên bản | Phù 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àng | Giữ 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-instructhoặcphi-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ường | Mặc định | Mô tả |
|---|---|---|
TASKFLOW_SEGMENT_STRATEGY | head_tail_only | Chiến lược chọn đoạn: head_tail_only, head_tail_scored, mapreduce_all |
TASKFLOW_CHUNK_SIZE | 5800 | Kích thước mỗi đoạn (≈1.4k tokens) |
TASKFLOW_HEAD_COUNT | 2 | Số đoạn đầu được giữ lại |
TASKFLOW_TAIL_COUNT | 2 | Số đoạn cuối được giữ lại |
TASKFLOW_TOP_RATIO | 0.45 | Tỷ 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_THRESHOLD | 28500 | Ngưỡng kích hoạt tóm tắt đầy đủ |
TASKFLOW_MAX_INLINE_CHARS | 11500 | Ngưỡ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ữ