·

Khi một AI agent gọi tool (tool call), phần lớn kỹ sư chỉ quan tâm "tool có chạy đúng không", mà quên rằng cái quay trở lại — tool result — mới là thứ ăn vào context window nhiều nhất. Một lệnh gọi API trả về nguyên khối JSON 15.000 token, một lần đọc file trả về toàn bộ nội dung 500 dòng, một câu query database trả về 300 record thô — tất cả những thứ đó không biến mất sau khi agent "đọc" xong. Chúng nằm lại trong lịch sử hội thoại (conversation history), được gửi lại cho LLM ở MỌI turn tiếp theo, cho đến khi bị cắt bớt hoặc hết context.

Bài này đi sâu vào 5 kỹ thuật cụ thể để xử lý tool result một cách có kỷ luật: trimming, filtering, summarization, structured extraction, và caching/deduplication. Đây là nhóm kỹ thuật có ROI (return on investment) cao nhất trong toàn bộ chủ đề tối ưu token, vì tool result thường chiếm 60-80% tổng token tiêu thụ trong một agent loop dài.

Hiểu cách Tool Result đi vào Context

Trước khi tối ưu, cần hiểu chính xác cơ chế. Trong một agent loop tiêu chuẩn (dùng Claude, GPT, hay bất kỳ LLM hỗ trợ tool use nào), vòng lặp diễn ra như sau:

  1. LLM sinh ra một tool_use block — tên tool và tham số. Phần này thường rất nhỏ (vài chục đến vài trăm token).
  2. Hệ thống (agent runtime) thực thi tool thật (gọi API, đọc file, query DB...).
  3. Kết quả trả về được đóng gói thành một tool_result message, và được append vào lịch sử hội thoại.
  4. Toàn bộ lịch sử (bao gồm tool_result đó) được gửi lại cho LLM ở turn kế tiếp để nó quyết định bước tiếp theo.

Vấn đề nằm ở bước 3 và 4: hầu hết framework và SDK, theo mặc định, sẽ nhồi toàn bộ raw output của tool vào tool_result — không cắt, không lọc, không tóm tắt. Nếu tool là REST API wrapper, bạn nhận luôn cả response envelope (headers, pagination metadata, _links, trường null không dùng tới). Nếu tool là "read_file", bạn nhận cả 1MB nội dung dù agent chỉ cần dòng 40-60.

Hệ quả tích lũy: trong một agent loop thực hiện 10-15 tool call (rất bình thường với coding agent hay research agent), tổng token của các tool_result có thể lên tới 50.000-100.000 token — vượt xa phần system prompt hay user message. Ba tác động tiêu cực:

  • Chi phí: mỗi token tool_result bị gửi lại NHIỀU LẦN (mỗi turn tiếp theo đều phải gửi lại toàn bộ history), nên chi phí nhân lên theo số turn còn lại.
  • Chất lượng suy luận: hiện tượng "lost in the middle" — LLM khó truy xuất chính xác thông tin quan trọng khi nó bị chôn giữa hàng nghìn token nhiễu (noise).
  • Giới hạn cứng: chạm context window limit sớm hơn dự kiến, buộc phải cắt bớt lịch sử (mất thông tin) hoặc dừng task giữa đường.

Điểm mấu chốt cần khắc sâu: tool result không cần phải "trung thực 100%" với output gốc của tool — nó chỉ cần đủ để agent đưa ra quyết định đúng ở bước kế tiếp. Đây là kim chỉ nam cho cả 5 kỹ thuật dưới đây.

Mẹo

Trước khi tối ưu, hãy đo trước. Thêm một dòng log đơn giản đếm số token của mỗi tool_result (dùng tokenizer của model bạn dùng, ví dụ tiktoken hoặc endpoint count_tokens của Anthropic), rồi sort giảm dần theo kích thước sau một vài lần chạy thật. 80% trường hợp, chỉ 2-3 tool là "kẻ tốn token" — tập trung tối ưu đúng chỗ đó trước, đừng tối ưu dàn trải.

Kỹ thuật 1: Trimming — Giới hạn cứng và Pagination

Trimming là kỹ thuật đơn giản nhất và nên làm đầu tiên: áp giới hạn cứng (hard limit) lên output của tool ngay tại lớp thực thi tool, trước khi nó có cơ hội đi vào context.

Nguyên tắc: mọi tool có khả năng trả về tập kết quả không giới hạn (list, search, log query...) đều phải có:
- Một cận trên cứng cho số item hoặc số ký tự trả về.
- Thông báo rõ ràng cho agent biết có bị cắt hay không, và còn bao nhiêu item nữa (để agent chủ động quyết định có cần xem thêm không).
- Cơ chế pagination (cursor hoặc offset) để agent tự lấy thêm khi thực sự cần, thay vì nhận hết một lần.

Ví dụ: tool search_logs tìm log lỗi trong hệ thống, có thể trả về hàng nghìn dòng khớp.

def search_logs(query: str) -> dict:
    matches = log_index.search(query)  # could be 3000+ rows
    return {"results": matches}


MAX_RESULTS_PER_CALL = 20

def search_logs(query: str, cursor: str | None = None) -> dict:
    all_matches = log_index.search(query)
    total = len(all_matches)

    start = decode_cursor(cursor) if cursor else 0
    page = all_matches[start : start + MAX_RESULTS_PER_CALL]
    next_start = start + MAX_RESULTS_PER_CALL

    return {
        "results": page,
        "returned": len(page),
        "total_matches": total,
        "has_more": next_start < total,
        "next_cursor": encode_cursor(next_start) if next_start < total else None,
    }

Với thiết kế này, nếu query khớp 3.000 dòng log, agent chỉ nhận 20 dòng đầu cộng với thông tin total_matches: 3000, has_more: true. Agent — dựa trên system prompt hướng dẫn — sẽ tự quyết định: nếu 20 dòng đầu đã đủ để chẩn đoán lỗi, nó dừng; nếu cần thêm, nó gọi lại với cursor mới. Trong 90% trường hợp thực tế, agent không bao giờ cần lấy page thứ 2 — tiết kiệm gần như toàn bộ token của 2.980 dòng còn lại.

Với tool đọc file lớn, áp dụng tương tự bằng offset dòng (start_line, end_line) thay vì buộc phải đọc/trả toàn bộ file — đặc biệt hữu ích với coding agent duyệt file nguồn hàng nghìn dòng.

Mẹo

Đừng chỉ cắt độ dài chuỗi (string truncation) một cách mù quáng ở giữa câu — cắt theo đơn vị có nghĩa (item, dòng, record) và LUÔN kèm metadata has_more/total. Nếu agent không biết bị cắt, nó có thể đưa ra kết luận sai lệch (ví dụ nghĩ rằng "không có lỗi nào khác" trong khi thực ra chỉ thấy 20/3000 dòng).

Kỹ thuật 2: Filtering — Chỉ trả những gì liên quan

Trimming giải quyết bài toán "quá nhiều item". Filtering giải quyết bài toán khác: "mỗi item quá nhiều field không cần thiết". Rất nhiều tool wrapper API bên thứ ba (GitHub, Jira, Stripe, CRM...) trả về object với 30-80 field, nhưng agent thường chỉ cần 3-5 field để hoàn thành nhiệm vụ.

Cách làm: thêm một lớp projection (chiếu/lọc trường) giữa response gốc và response trả cho agent. Có hai chiến lược:

  1. Allowlist tĩnh theo từng loại tool — chọn trước bộ field "thường dùng nhất", loại phần còn lại.
  2. Tham số fields động — cho phép agent (qua tool schema) tự chỉ định field nào cần, giống cách GraphQL hoạt động.

Ví dụ với tool get_github_issue:

def get_github_issue(issue_number: int) -> dict:
    return github_client.get(f"/issues/{issue_number}").json()
    # includes: url, repository_url, labels_url, comments_url, node_id,
    # author_association, performed_via_github_app, reactions {...},
    # timeline_url, milestone, assignees[], locked, active_lock_reason...


RELEVANT_FIELDS = ["number", "title", "state", "labels", "assignee", "body"]

def get_github_issue(issue_number: int, fields: list[str] | None = None) -> dict:
    raw = github_client.get(f"/issues/{issue_number}").json()
    keep = fields or RELEVANT_FIELDS

    filtered = {k: raw[k] for k in keep if k in raw}
    # normalize noisy nested structures too
    if "labels" in filtered:
        filtered["labels"] = [lbl["name"] for lbl in filtered["labels"]]
    if "assignee" in filtered and filtered["assignee"]:
        filtered["assignee"] = filtered["assignee"]["login"]
    if "body" in filtered and filtered["body"]:
        filtered["body"] = filtered["body"][:500]  # excerpt, not full text

    return filtered

Kết quả: từ ~2,5KB (khoảng 700-800 token) xuống còn vài chục token, mà vẫn đủ thông tin để agent trả lời câu hỏi "issue này còn mở không, ai đang assign, label gì". Nếu agent thực sự cần trường bị lọc bỏ (hiếm), nó gọi lại với fields mở rộng — cơ chế "opt-in cho chi tiết" thay vì "opt-out sau khi đã nhận hết".

Filtering cũng áp dụng được cho output dạng bảng/CSV: chỉ giữ cột liên quan đến câu hỏi, bỏ cột ID nội bộ, timestamp hệ thống, cờ debug.

Mẹo

Khi thiết kế allowlist, hỏi câu: "Nếu thiếu field này, agent có ra quyết định sai không?" — chỉ giữ field trả lời "có". Đừng giữ field "có thể sẽ cần" — thêm lại sau này rẻ hơn nhiều so với việc âm thầm tốn token ở mọi lần gọi.

Kỹ thuật 3: Summarization — Nén output lớn bằng LLM phụ

Trimming và filtering hoạt động tốt với dữ liệu có cấu trúc (structured), nơi ta biết rõ field nào giữ, field nào bỏ. Nhưng với dữ liệu phi cấu trúc (unstructured) — nội dung file log dài, transcript cuộc gọi, tài liệu PDF, trang web scrape — không có "field" nào để lọc. Đây là lúc dùng summarization: gọi một model rẻ/nhanh (ví dụ Claude Haiku) làm bước trung gian để tóm tắt output tool TRƯỚC KHI nó vào context của agent chính (thường dùng model mạnh hơn, đắt hơn, như Claude Opus/Sonnet).

Đánh đổi cần cân nhắc: bước summarization tốn thêm một lệnh gọi LLM (thêm latency + một khoản chi phí nhỏ), nhưng đổi lại tiết kiệm token ở MỌI turn còn lại của agent loop chính — vì output đã được nén trước khi lưu vào history. Nếu agent loop còn dài (nhiều turn phía sau), phép tính này gần như luôn có lợi.

Nguyên tắc áp dụng: chỉ summarize khi output vượt một ngưỡng token nhất định (ví dụ >2.000 token) — với output nhỏ, summarization chỉ tốn thêm chi phí mà không lợi ích tương xứng.

from anthropic import Anthropic

client = Anthropic()
SUMMARIZE_THRESHOLD_TOKENS = 2000

def maybe_summarize(tool_name: str, raw_output: str, task_context: str) -> str:
    """Wrap around any tool execution. Summarize only if output is large."""
    if estimate_tokens(raw_output) <= SUMMARIZE_THRESHOLD_TOKENS:
        return raw_output

    summary_response = client.messages.create(
        model="claude-haiku-4-5-20251001",  # cheap, fast model for compression only
        max_tokens=400,
        messages=[{
            "role": "user",
            "content": (
                f"Task context: {task_context}\n\n"
                f"Tool `{tool_name}` returned the following output. "
                f"Summarize ONLY the parts relevant to the task context above. "
                f"Preserve exact numbers, error messages, and file paths verbatim. "
                f"Output as concise bullet points, max 300 words.\n\n"
                f"--- RAW OUTPUT ---\n{raw_output}"
            ),
        }],
    )
    return summary_response.content[0].text


def execute_tool_call(tool_name: str, args: dict, task_context: str) -> str:
    raw = TOOL_REGISTRY[tool_name](**args)
    return maybe_summarize(tool_name, raw, task_context)

Điểm quan trọng trong prompt tóm tắt: yêu cầu giữ nguyên chính xác số liệu, đường dẫn file, mã lỗi — vì đây là những chi tiết agent chính có thể cần dùng lại nguyên văn (ví dụ để gọi tool khác). Tóm tắt kiểu "diễn giải ý chung" mà làm mất số liệu cụ thể sẽ khiến agent chính đưa ra hành động sai ở bước sau.

Lưu ý rủi ro: summarization là bước có mất thông tin (lossy) — không dùng cho dữ liệu mà độ chính xác tuyệt đối là bắt buộc (ví dụ dữ liệu tài chính dùng để tính toán tiếp). Với các trường hợp đó, ưu tiên Kỹ thuật 4 (structured extraction) thay vì tóm tắt tự do.

Mẹo

Cache kết quả summarization theo hash của raw output — nếu agent (hoặc một agent khác trong hệ thống multi-agent) gọi lại đúng tool với đúng tham số trong cùng session, dùng lại summary đã tính, không tốn thêm lệnh gọi LLM phụ.

Kỹ thuật 4: Structured Extraction — Trả output khớp Schema

Structured extraction khác filtering ở điểm cốt lõi: filtering hoạt động trên dữ liệu ĐÃ có cấu trúc (JSON, bảng), còn structured extraction dùng để BIẾN dữ liệu phi cấu trúc/bán cấu trúc (HTML thô, PDF, văn bản tự do) thành một object nhỏ, khớp đúng schema định nghĩa trước — ngay tại lớp thực thi tool, trước khi trả về agent.

Ví dụ điển hình: tool "scrape trang sản phẩm e-commerce" để lấy giá và tình trạng còn hàng.

from pydantic import BaseModel
from anthropic import Anthropic

client = Anthropic()

class ProductInfo(BaseModel):
    name: str
    price_usd: float
    in_stock: bool
    rating: float | None = None


def scrape_product_page(url: str) -> str:
    html = fetch(url)
    return html  # entire page: nav bar, footer, scripts, ads, related items...


def scrape_product_page(url: str) -> dict:
    html = fetch(url)
    clean_text = strip_boilerplate(html)  # remove nav/footer/scripts first

    extraction = client.messages.create(
        model="claude-haiku-4-5-20251001",
        max_tokens=200,
        tools=[{
            "name": "record_product_info",
            "description": "Record extracted product information.",
            "input_schema": ProductInfo.model_json_schema(),
        }],
        tool_choice={"type": "tool", "name": "record_product_info"},
        messages=[{"role": "user", "content": f"Extract product info:\n{clean_text}"}],
    )
    return extraction.content[0].input  # {"name": "...", "price_usd": 29.99, ...}

So sánh trực tiếp: output free-form (nguyên trang HTML) tốn khoảng 18.000 token — chủ yếu là boilerplate không liên quan (menu điều hướng, footer, script quảng cáo, sản phẩm liên quan). Output structured chỉ còn 4 field cố định, khoảng 25 token — giảm hơn 700 lần, mà vẫn giữ đủ 100% thông tin agent cần để trả lời "sản phẩm này giá bao nhiêu, còn hàng không".

Lợi ích kép của structured extraction: (1) giảm token cực mạnh, và (2) đầu ra có schema cố định nên agent chính dễ dùng lại (parse, so sánh, tính toán) hơn nhiều so với việc tự "đọc hiểu" một đoạn văn tóm tắt tự do. Nhược điểm: cần định nghĩa schema trước cho từng loại tool — không linh hoạt bằng summarization tự do khi loại dữ liệu đa dạng, khó đoán trước cấu trúc.

Mẹo

Với structured extraction, luôn cho field optional (| None) đối với dữ liệu không phải lúc nào cũng có (như rating ở ví dụ trên) — tránh việc model "bịa" giá trị để khớp schema bắt buộc, gây hallucination trong dữ liệu tưởng như đã "sạch".

Kỹ thuật 5: Caching và Deduplication Kết quả

Bốn kỹ thuật trên xử lý output của MỘT lần gọi tool. Kỹ thuật thứ 5 xử lý một vấn đề khác: agent loop, đặc biệt là loop nhiều bước hoặc hệ multi-agent, thường gọi LẶP LẠI cùng một tool với cùng tham số — đọc lại một file chưa đổi, query lại API chưa hết TTL, hoặc hai sub-agent song song cùng tra cứu một thông tin. Mỗi lần gọi lặp lại như vậy vừa tốn tiền thực thi tool, vừa tốn token đưa nguyên kết quả (thường đã có sẵn trong context từ trước) vào lần nữa.

Giải pháp: cache kết quả tool theo key là hash của (tool_name, tham số đã chuẩn hóa), với TTL phù hợp (chỉ áp dụng cho tool read-only, không có side effect — không bao giờ cache tool có ghi/sửa/xóa dữ liệu).

import hashlib
import json
import time

_tool_cache: dict[str, tuple[float, str]] = {}  # key -> (expires_at, result)

READ_ONLY_TOOLS = {"get_file_contents", "search_logs", "get_github_issue"}
DEFAULT_TTL_SECONDS = 300

def cache_key(tool_name: str, args: dict) -> str:
    normalized = json.dumps(args, sort_keys=True)
    return hashlib.sha256(f"{tool_name}:{normalized}".encode()).hexdigest()

def execute_with_cache(tool_name: str, args: dict) -> tuple[str, bool]:
    """Returns (result, was_cache_hit)."""
    if tool_name not in READ_ONLY_TOOLS:
        return TOOL_REGISTRY[tool_name](**args), False  # never cache writes

    key = cache_key(tool_name, args)
    cached = _tool_cache.get(key)
    if cached and cached[0] > time.time():
        return cached[1], True

    result = TOOL_REGISTRY[tool_name](**args)
    _tool_cache[key] = (time.time() + DEFAULT_TTL_SECONDS, result)
    return result, False

Khi cache hit, thay vì nhồi lại toàn bộ result vào context (dù đã có ở lượt trước), agent runtime có thể trả một tool_result rất ngắn dạng: "[cached — identical to result from step 3, unchanged]". Điều này vừa tránh gọi lại tool thật, vừa tránh trùng lặp token trong context — đây chính là phần deduplication: không phải trùng lặp lệnh gọi, mà là trùng lặp NỘI DUNG đã tồn tại trong lịch sử hội thoại.

Deduplication còn áp dụng trong trường hợp không hoàn toàn giống 100% tham số nhưng kết quả trùng lặp về nội dung — ví dụ agent đọc file A ở bước 2, rồi lại đọc đúng file A (chưa thay đổi) ở bước 9 sau khi đã đọc nhiều file khác ở giữa. Runtime có thể so sánh hash nội dung file với lần đọc trước, và nếu giống, trả về tham chiếu ngắn thay vì nội dung đầy đủ lần nữa.

Ví dụ tool NÊN cache: get_file_contents (file chưa sửa), search_logs với cùng query trong vài phút, get_weather theo địa điểm/giờ. Ví dụ tool KHÔNG được cache: create_invoice, send_email, delete_record, hoặc bất kỳ tool có side effect / phụ thuộc thời gian thực mà giá trị "mới nhất" quan trọng hơn tốc độ (ví dụ get_stock_price trong hệ thống giao dịch).

Mẹo

Đặt TTL theo đặc tính dữ liệu, không dùng một giá trị chung cho tất cả tool: file source code gần như tĩnh trong một session (TTL vài phút đến cả session), log real-time có thể cần TTL vài chục giây, giá cổ phiếu có khi cần TTL bằng 0 (không cache). Cache sai TTL còn nguy hiểm hơn không cache — agent sẽ hành động dựa trên dữ liệu cũ mà không biết.

Tổng kết

Tool result là nguồn tiêu thụ token lớn nhất trong hầu hết agent loop, nhưng cũng là nơi dễ tối ưu nhất vì ta có toàn quyền kiểm soát nó ở lớp thực thi tool — trước khi bất kỳ token nào chạm tới LLM. Năm kỹ thuật trong bài, xếp theo thứ tự nên áp dụng:

  1. Trimming — chặn ngay tại nguồn số lượng item/độ dài bằng hard limit + pagination, luôn kèm metadata has_more/total để agent không hiểu sai.
  2. Filtering — chỉ giữ field thực sự cần cho quyết định của agent, dùng allowlist hoặc tham số fields động.
  3. Summarization — dùng model rẻ/nhanh nén dữ liệu phi cấu trúc lớn, chấp nhận mất thông tin không quan trọng, giữ nguyên số liệu/đường dẫn cần chính xác.
  4. Structured extraction — biến dữ liệu phi cấu trúc thành object khớp schema cố định, gần như không mất thông tin cần thiết, giảm token mạnh nhất trong 5 kỹ thuật.
  5. Caching & deduplication — tránh gọi lại và tránh nhồi lại nội dung trùng lặp cho tool read-only, dựa trên hash tham số hoặc hash nội dung.

Trong thực tế, các kỹ thuật này không loại trừ nhau — nên kết hợp theo tầng: filter trước để loại field rác, rồi trim nếu số lượng vẫn lớn, cache nếu là read-only, và chỉ dùng summarization/structured extraction cho phần dữ liệu phi cấu trúc còn lại. Một agent áp dụng đủ 5 kỹ thuật này thường giảm được 70-90% token tiêu thụ từ tool result so với việc dùng output thô mặc định — đây là khoản tiết kiệm lớn nhất, dễ đo lường nhất trong toàn bộ chiến lược tối ưu token cho AI agent.