Agent:在引擎之上手写的「LLM ↔ 工具」循环
本章讲 Haystack 的 agentic 那一半。关键认知:Agent 不是用 Pipeline 图实现的,而是一个
@component内部手写的while循环——它本身也是一个组件,能被塞进更大的管道。
1. Agent 是什么
Agent(components/agents/agent.py:104)是「一个会用工具的 LLM」。run(messages) 后它会反复:
┌────────────────────────────────────────────────┐
│ 1. 把当前消息历史发给 chat_generator(LLM) │◀────┐
│ 2. LLM 回复里有 tool_call 吗? │ │
│ 没有 → 退出(默认 exit_conditions=["text"]) │ │
│ 有 → 3. ToolInvoker 执行这些工具 │ │
│ 4. 把工具结果作为消息回灌进历史 │─────┘
│ 命中 exit_conditions 的工具 → 退出 │
└────────────────────────────────────────────────┘
没工具时,Agent 就退化成一个普通 ChatGenerator(构造时会 warn,agent.py:333-337)。
2. 三个零件
| 零件 | 角色 | 在哪 |
|---|---|---|
chat_generator | 调 LLM,必须支持 tools 参数 | 构造时校验,agent.py:262-268 |
ToolInvoker | 真正执行 LLM 想调的工具 | components/tools/tool_invoker.py:82 |
State | 跨步骤累积的「记忆」(消息历史 + 自定义键) | components/agents/state/state.py:82 |
3. 核心机制:循环主体
看 Agent.run(agent.py:726)的 while exe_context.counter < self.max_agent_steps(agent.py:809):
第一步——问 LLM(agent.py:816-827):注意它复用了管道引擎的单组件执行器 Pipeline._run_component,而不是自己直 接调 chat_generator.run。这样 tracing、错误包装、断点逻辑全都共享。回复存进 state 的 messages(agent.py:858)。
退出判断(agent.py:860-872):当没有 ToolInvoker,或者「最后一条消息是非空的 assistant 文本且没有任何 tool_call」时,退出。注释强调要求最后一条是非空 assistant 文本,免得一个空回复就误触发退出(agent.py:860-863)。
第二步——执行工具(agent.py:895-909):同样走 Pipeline._run_component 跑 ToolInvoker,输入是 LLM 那几条带 tool_call 的消息 + 当前 state。工具结果消息回灌进 state(agent.py:941-943)。
显式退出条件(agent.py:945-948):如果 exit_conditions 不是默认的 ["text"],而是某些工具名,_check_exit_conditions(agent.py:1209)检查 LLM 是否调了这些工具且没出错——是则退出。注释点明它检查每个 tool_call,所以并行工具调用的顺序无关紧要(agent.py:1213-1215)。
4. State:Agent 的记忆
State(state.py:82)是一个带 schema 的键值容器。每个键有一个 type 和一个合并 handler:
- list 类型默认用
merge_lists(追加),state.py:139; - 其他类型默认用
replace_values(覆盖),state.py:141。
这解释了为什么消息能「累积」:messages 键是 list[ChatMessage] + merge_lists(agent.py:289),每轮 state.set("messages", 新消息) 是追加而非覆盖。而 Agent 在替换整段历史时(确认策略改写后)会显式传 handler_override=replace_values 来覆盖(agent.py:893)。
Agent 的输入/输出口是动态的:__init__ 里遍历 state_schema,每个键都 component.set_input_type / 汇总进 set_output_types(agent.py:304-311)——这正是第 2 章 §2.4 动态 socket 的实战。所以工具可以通过 inputs_from_state / outputs_to_state 读写这些状态键。
5. ToolInvoker:并行执行工具
ToolInvoker.run(tool_invoker.py:566):
- 只保留带 tool_call 的消息(
tool_invoker.py:626); - 准备每个工具调用的参数,包括从 State 注入参数(
_inject_state_args); - 用线程池并行执行所有工具调用(
tool_invoker.py:649,ThreadPoolExecutor); - 逐个收结果:出错时若
raise_on_failure=False就把错误包成一条 tool 消息回给 LLM(tool_invoker.py:659-664),否则抛;成功则把输出 merge 进 State 并造一条 tool 结果消息(tool_invoker.py:665-674)。
「错误变成消息回灌给 LLM」是个关键设计:让模型自己看到工具报错、决定要不要重试或换路(raise_on_tool_invocation_failure 默认 False,agent.py:329)。
Tool 本身(tools/tool.py:19)是个 dataclass:name / description / parameters(JSON schema) / function。invoke(tool.py:261)就是 self.function(**kwargs),包一层 ToolInvocationError。工具函数必须是同步的——__post_init__ 显式拒绝 async(tool.py:105-110)。
6. 断点与恢复
Agent 支持在 chat_generator 或某个工具处下断点(AgentBreakpoint / ToolBreakpoint),触发时把整个执行上下文存成 AgentSnapshot,之后能从断点恢复(_initialize_from_snapshot,agent.py:645)。恢复时若断在工具处,会跳过下一次 LLM 调用(skip_chat_generator,agent.py:677,811)。这套断点机制和管道的 pipeline_snapshot(第 1 章)共享底层(core/pipeline/breakpoint.py),是 Haystack 面向「生产可调试」的体现。
下一章:巧妙之处、边界局限、横向对比和完整代码地图。见 05-clever-and-boundaries.md。