Tool Result Offloading

Tool Result Offloading

도구 결과 오프로딩은 선택한 도구 결과를 스토어에 작성하고, 대화에서는 그것을 컴팩트한 포인터(참조 + 짧은 미리보기)로 바꿔 넣어요. 그래서 다음 LLM 호출은 전체 결과 대신 참조만 보게 돼요.

출처: 문서

본문

이 기능은 도구가 큰 출력(웹 페이지, 파일 내용, 쿼리 결과)을 반환할 때 컨텍스트 창을 작게 유지해 주고, 나아가 Agent가 참조된 파일을 여는 파일 읽기 도구 같은 후속 도구로 오프로드된 결과를 다룰 수 있는 길을 열어줘요.

Agent 컴포넌트에 after_tool 훅 포인트로 등록되는 ToolResultOffloadHook 으로 구성돼요. 핵심 클래스는 ToolResultOffloadHook, FileSystemToolResultStore, AlwaysOffload, NeverOffload, OffloadOverChars 예요. 임포트 경로는 haystack.hooks.tool_result_offloading 이에요.

개요 (Overview)

도구 결과 오프로딩은 Agent의 일반 훅 메커니즘의 한 응용이에요. after_tool 훅 포인트에 등록된 ToolResultOffloadHook 은 각 스텝의 도구가 실행된 뒤 실행되어, 방금 생성된 도구 결과 메시지를 Agent의 State 에서 다시 써요. 현재 스텝의 결과만 다루고, 이전 대화 기록은 건드리지 않아요.

이 시스템은 다음 계층으로 구성돼요:

  • ToolResultOffloadHook — 새 도구 결과에 오프로드 전략을 적용하는 after_tool 훅. 그 offload_strategies 매핑은 단일 도구 이름, 도구 이름 튜플, 또는 특정 항목이 없는 어떤 도구에도 적용되는 와일드카드 "*" 를 받아요.
  • 정책(Policy) — 주어진 결과가 오프로드될지 결정해요. 내장 정책: AlwaysOffload, NeverOffload, OffloadOverChars.
  • 스토어(Store) — 전체 결과가 어디에 사는지 결정해요. 내장 FileSystemToolResultStore 는 결과를 로컬 파일 시스템에 작성해요.

결과가 오프로드되면 훅은 결과의 각 부분을 스토어에 쓰고, 그 자리에 컴팩트한 포인터로 메시지를 재구성해요. 예를 들어:

Tool result offloaded to text (18234 characters) at '/abs/path/tool_results/2_search_call-123.txt'. Preview: Fusion startups reported...

포인터는 스토어 참조, 원본 크기, 그리고 텍스트의 경우 처음 preview_chars 개 문자(기본 200, 훅에서 설정 가능)의 미리보기를 담아서, 모델이 무엇이 오프로드됐고 어디에 있는지 대략 알 수 있어요.

여러 부분(텍스트·이미지·파일의 어떤 조합)으로 된 결과는 부분마다 스토어 항목 하나와 포인터 줄 하나를 만듭니다:

Tool result offloaded to 3 files:
1. text (412 characters) at '/abs/path/tool_results/2_fetch_call-123_0.txt'. Preview: Quarterly report attached...
2. image/png (48210 bytes) at '/abs/path/tool_results/2_fetch_call-123_1.png'
3. application/pdf named 'q3.pdf' (1048576 bytes) at '/abs/path/tool_results/2_fetch_call-123_2.pdf'

이미지와 파일은 base64 페이로드에서 디코딩되어 원시 바이트로 저장돼요. base64 페이로드는 파일을 대화로 옮기는 비용이 큰 방식이라, 하나를 밖으로 옮기면 컨텍스트 창의 상당 부분을 확보할 수 있어요.

사용법 (Usage)

기본 설정 (Basic setup)

아래 예시는 4,000자보다 긴 도구 결과를 로컬 tool_results 디렉토리 아래 파일로 오프로드해요:

from typing import Annotated
from haystack.components.agents import Agent
from haystack.components.generators.chat import OpenAIChatGenerator
from haystack.dataclasses import ChatMessage
from haystack.hooks.tool_result_offloading import (
    FileSystemToolResultStore,
    OffloadOverChars,
    ToolResultOffloadHook,
)
from haystack.tools import tool

@tool
def search(query: Annotated[str, "The search query"]) -> str:
    """Search the web and return the (potentially large) results."""
    # Placeholder: would call a real search API
    return f"... large result for {query} ..."

offload_hook = ToolResultOffloadHook(
    store=FileSystemToolResultStore(root="tool_results"),
    offload_strategies={"*": OffloadOverChars(4000)},
)
agent = Agent(
    chat_generator=OpenAIChatGenerator(model="gpt-5.4-nano"),
    tools=[search],
    hooks={"after_tool": [offload_hook]},
)
result = agent.run(messages=[ChatMessage.from_user("Summarize today's tech news")])

도구별로 무엇을 오프로드할지 설정 (Configuring what gets offloaded per tool)

offload_strategies 의 각 키는 단일 도구 이름, 한 정책을 공유하는 도구 이름 튜플, 또는 와일드카드 "*" 일 수 있어요. 더 구체적인 키가 "*" 보다 우선하고, 일치하는 키도 "*" 도 없는 도구는 절대 오프로드되지 않아요:

from haystack.hooks.tool_result_offloading import (
    AlwaysOffload,
    FileSystemToolResultStore,
    NeverOffload,
    OffloadOverChars,
    ToolResultOffloadHook,
)

offload_hook = ToolResultOffloadHook(
    store=FileSystemToolResultStore(root="tool_results"),
    offload_strategies={
        "web_search": AlwaysOffload(),  # force offload
        "get_time": NeverOffload(),  # opt out of the wildcard default
        ("read_file", "list_dir"): OffloadOverChars(4000),  # tuple key: shared policy
        "*": OffloadOverChars(8000),  # default for any unlisted tool
    },
)

무엇이 오프로드되는가 (What is offloaded)

훅은 성공한 도구 결과만 오프로드해요:

  • 오류 결과 — before_tool Human-in-the-Loop 훅이 만든 거부(rejection)를 포함 — 항상 컨텍스트에 남겨서 모델이 무엇이 잘못됐는지 보게 해요.
  • 텍스트, 이미지, 파일 결과는 모두 오프로드돼요. 결과의 각 부분은 스토어 항목을 따로 가지므로, 각 텍스트·이미지·파일이 각자 단독으로 쓰일 수 있어요.
  • 이미지와 파일 콘텐츠는 supports_binary_content 를 선언한 스토어에만 들어가요. 텍스트 전용 스토어에서는 이미지나 파일을 실은 결과가 컨텍스트에 남고 경고가 로그로 남아요.
  • 이미지나 파일의 파일 확장자는 filename 이 있으면 그 값에서, 없으면 mime_type 에서 따와요(둘 다 확장자를 못 내주면 .bin 으로 대체).
  • 정책은 결과를 모든 부분의 텍스트와 base64 페이로드를 합친 것으로 봐요.
  • 훅이 매 도구 스텝마다 실행돼도 각 결과는 최대 한 번만 오프로드돼요. 그래서 after_tool 아래에 두 개의 오프로드 훅을 등록해도 서로의 포인터는 오프로드하지 않아요.

정책 (Policies)

정책은 결과가 오프로드되는지 제어해요.

정책 동작
AlwaysOffload 할당된 도구의 모든 결과를 오프로드해요.
NeverOffload 절대 오프로드하지 않아요 — 전체 결과를 컨텍스트에 유지해요(와일드카드 기본값에서 도구를 빼내는 데 유용).
OffloadOverChars(threshold) 결과가 threshold 자보다 길 때만 오프로드해요.

커스텀 정책 (Custom policy)

커스텀 조건을 위해 haystack.hooks.tool_result_offloading 의 OffloadPolicy 프로토콜을 서브클래싱하세요. 정책은 should_offload 메서드가 필요하며, 이 메서드는 도구 이름, 결과 텍스트, Agent의 라이브 State를 받아서 실행 컨텍스트를 기준으로도 결정할 수 있어요:

from haystack.components.agents.state import State
from haystack.hooks.tool_result_offloading import OffloadPolicy

class OffloadLateSteps(OffloadPolicy):
    """Offload results only once the run is several steps deep and context pressure builds up."""

    def should_offload(self, tool_name: str, result: str, state: State) -> bool:
        return state.data.get("step_count", 0) >= 3 and len(result) > 1000

프로토콜은 기본 to_dict / from_dict 구현을 제공해서, 생성자가 인자를 받지 않는 이런 정책은 그대로 직렬화 가능해요. 생성자 인자가 있는 정책은 OffloadOverChars 를 예시로 삼아 두 메서드를 직접 구현해야 해요.

스토어 (Stores)

FileSystemToolResultStore

FileSystemToolResultStore(root=...) 는 각 오프로드 결과를 루트 디렉토리 아래 파일로 쓰고, 참조로 절대 파일 경로를 반환해요. 디렉토리는 처음 쓸 때 만들어져요. 스토어 키는 스텝 수, 도구 이름, 도구 호출 ID(예: 2_search_call-123.txt)와, 결과가 여러 항목에 걸칠 때엔 결과 내 부분 위치(2_search_call-123_1.png)에서 파생돼요. 그래서 다른 도구·스텝의 결과가 충돌하지 않아요. 루트 디렉토리 밖으로 해석될 키는 거부돼요.

텍스트는 UTF-8로 쓰고 문자열로 읽으며, 이미지와 파일은 원시 바이트로 쓰고 bytes 로 읽어요.

커스텀 스토어 (Custom store)

ToolResultStore 프로토콜을 서브클래싱해 객체 스토리지나 격리된 샌드박스 파일 시스템 같은 다른 백엔드를 대상으로 삼을 수 있어요. 스토어에는 두 메서드가 필요해요: write(key=..., content=...) 는 콘텐츠를 저장하고 참조 문자열을 반환하고, read(reference) 는 그 참조를 콘텐츠로 다시 해석해요. 참조를 해석하는 것은 스토어뿐이고, 나머지는 변경 없이 read 로 다시 전달해요:

from haystack.hooks.tool_result_offloading import ToolResultStore

class InMemoryToolResultStore(ToolResultStore):
    """Keep offloaded results in a dict - useful for tests."""

    def __init__(self) -> None:
        self._data: dict[str, str] = {}

    def write(self, *, key: str, content: str) -> str:
        self._data[key] = content
        return key

    def read(self, reference: str) -> str:
        return self._data[reference]

이런 스토어는 텍스트만 받아요: supports_binary_content 의 기본값이 False 라서, 훅은 쓸 수 없는 바이트를 넘기는 대신 이미지와 파일 결과를 컨텍스트에 남겨요. 바이트를 담을 수 있는 스토어는 플래그를 설정하고 두 시그니처를 넓히면 돼요:

class BinaryCapableStore(ToolResultStore):
    supports_binary_content = True

    def write(self, *, key: str, content: str | bytes) -> str: ...
    def read(self, reference: str) -> str | bytes: ...

OffloadPolicy 처럼 프로토콜은 생성자가 인자를 받지 않는 스토어를 다루는 기본 to_dict / from_dict 구현을 제공하고, 생성자 인자가 있는 스토어는 두 메서드를 직접 구현해야 해요.

hook_context 를 통한 실행별 스토어 (Per-run stores via hook_context)

생성자 store 는 모든 실행이 공유해요 — 단일 사용자 또는 로컬 사용에 적합해요. 멀티 유저 서버에서는 각 실행에 고립된 스토어(예: 세션별 디렉토리)를 Agent의 범용 hook_context 실행 인자에 RESULT_STORE_CONTEXT_KEY 키 아래 전달해 주세요. 이 값은 그 실행에 한해 생성자 스토어를 오버라이드해요:

from haystack.hooks.tool_result_offloading import (
    RESULT_STORE_CONTEXT_KEY,
    FileSystemToolResultStore,
)

per_request_store = FileSystemToolResultStore(root=f"tool_results/{session_id}")
result = agent.run(
    messages=[ChatMessage.from_user("...")],
    hook_context={RESULT_STORE_CONTEXT_KEY: per_request_store},
)

실행별로 스토어를 격리하면 동시 사용자가 스토어 키에서 충돌하거나 서로의 오프로드 결과를 읽는 일을 막아줘요 — 특히 파일 읽기 도구가 스토어에 한정될 때 중요해요. 훅 자체는 가변 상태를 유지하지 않으므로, 단일 인스턴스를 동시 실행에 공유해도 안전해요.

Agent가 오프로드 결과를 다시 읽게 하기 (Letting the Agent read offloaded results back)

대화에 남은 포인터는 모델에게 전체 결과가 어디 있는지 알려줘요. 하지만 모델은 Agent가 스토어에서 읽을 수 있는 도구를 가져야만 그 위에서 행동할 수 있어요. FileSystemToolResultStore 를 쓰면 간단한 파일 읽기 도구가 될 수 있어요:

from typing import Annotated
from haystack.tools import tool

@tool
def read_offloaded_result(
    path: Annotated[str, "Absolute path of an offloaded tool result"],
) -> str:
    """Read back the full content of an offloaded tool result."""
    content = FileSystemToolResultStore(root="tool_results").read(path)
    if isinstance(content, bytes):
        return f"'{path}' holds {len(content)} bytes of binary content and cannot be read as text."
    return content

이 도구를 쓰면 Agent는 컴팩트한 대화로 작업하면서, 매 LLM 호출마다 모든 전체 결과를 컨텍스트에 실어 나르는 대신 필요한 오프로드 결과만 선택적으로 다시 읽을 수 있어요. 오프로드된 이미지나 파일은 bytes 로 돌아오는데, 다시 모델 앞에 놓으려면 도구가 텍스트 대신 ImageContent 나 FileContent 블록으로 반환할 수 있어요.

직렬화 (Serialization)

ToolResultOffloadHook 은 to_dict / from_dict 를 구현하므로, 설정된 스토어와 정책도 직렬화 가능하다면 이를 쓰는 Agent를 직렬화할 수 있어요. 내장 스토어와 정책은 모두 그렇고, 커스텀 스토어·정책은 위의 정책과 스토어 섹션의 참고 사항을 따르세요.

추가 자료 (Additional References)

📖 관련 문서:

  • Hooks — 이 기능의 기반 메커니즘(after_tool 훅 포인트 포함)
  • Human in the Loop — 도구 호출을 사람 검토용으로 가로채는 또 다른 준비된 훅
  • State — 훅과 정책이 받는 라이브 런 상태

더 알아보기 (Learn more)