跳到主要内容

haystack — 本课题摘录

读了哪几篇: 04-agent-loop(在引擎之上手写的 LLM↔工具循环)。 其余四篇(管道引擎、组件契约、RAG 路径、巧妙之处)本轮没读——属 RAG 与工作流课题。

这一家最值钱的一条:它把"这一轮的多个工具调用谁能并行"变成了一道可计算的依赖分析题。

它对本课题回答了什么

决定一(架构):有图引擎,但 agent 不用图引擎

一个值得记的取舍: 它自己有一套成熟的管道图引擎,但 agent 不是一张图,而是引擎之上手写的一个 while。 (依据:前沿库 · Haystack · Haystack Agent —— 引擎之上手写的 LLM↔工具循环 —— Haystack 的 Agent 不是 Pipeline 图,而是手写的 while 循环;理由是管道引擎调度的是一次性 DAG 数据流,而 agent 要的是「LLM 看结果再决定下一步」的循环)

它给的理由:管道引擎调度的是"一次性 DAG 数据流",而 agent 要的是"看结果再决定下一步"。两件事不一样,不硬凑。

这跟 langchain / crewai 新执行器"把循环编译成图"是直接对立的判断。 有意思的是:haystack 有图引擎却选择不用,而 langchain 没有循环引擎却把循环做成了图。

决定一:每一步重铺工具列表

不是循环开始时铺一次,是每一步都重算。 (依据:前沿库 · Haystack · Haystack Agent —— 引擎之上手写的 LLM↔工具循环 —— 每步先跑 flatten_tools_or_toolsets 重算工具列表,让动态工具集在后续步骤发现的新工具能冒出来,重名在这一步就炸)

两个连带好处:

  1. 动态工具集在后续步骤发现的新工具能冒出来;
  2. 重名在这一步就炸,不带病上路。

决定三:工具并发按"读写哪些状态键"排序 —— 本课题最细的一份

这是这一家的招牌。 它不是"全并行"也不是"全串行",而是分析每个工具调用读哪些状态键、写哪些状态键,做一次分层拓扑排序:

规则说明
读后写读某个键的调用,必须排在写同一个键的调用之后
同批并行同一批里互相无依赖的,并行跑
确定性破环依赖成环时(一个工具又读又写同一键、还被调了两次),按调用序号确定性地破环
写-写不产生依赖没人读就不怕撞,结果稍后按调用序合并,照样确定

(依据:前沿库 · Haystack · Haystack Agent —— 引擎之上手写的 LLM↔工具循环 —— _schedule_tool_calls 分析每个调用读写哪些 State 键做分层拓扑排序:读某键必须排在写同键之后、同批无依赖则并行、成环时按调用序确定性破环、纯写-写冲突不产生依赖)

执行时逐批并行,每批开头才准备参数——让读状态的工具看到前一批写入的东西。批内按调用序收结果合并,返回的消息列表也保持调用序。

这条把"并行还是串行"从拍脑袋变成了可计算。 pydantic-ai 的"栅栏工具"是让工具自己声明"我不能并发"; openai-agents-js 是按类型硬分(函数并行、改文件系统的串行); haystack 是从读写集合自动推出来的,粒度最细,也最不需要人工标注。

决定三补充:错误变成消息回灌

跟 cline 一样:工具出错时,如果配置成"不抛",就把错误包成一条工具消息回给模型,让它自己看到报错、决定重试还是换路。

决定四:三种退出理由,写在文档里

退出理由触发
文本没工具可用,或最后一条消息是非空助手文本且没有任何工具调用
某个工具名配置了"调到这几个工具就算完",且模型调了且没出错
步数上限撞上限

(依据:前沿库 · Haystack · Haystack Agent —— 引擎之上手写的 LLM↔工具循环 —— exit_reason 三种取值 "text" / 工具名 / "max_agent_steps",文本退出要求「非空 assistant 文本且没有任何 tool_call」)

"要求非空文本"这个细节值得记: 不这么写的话,模型返回一条空回复就会被误当成"它说完了"。

决定一补充:记忆是一个带合并规则的键值容器

跨步骤的状态是一个带 schema 的键值容器,每个键自带一个合并方式:

  • 列表类型默认追加;
  • 其它类型默认覆盖

(依据: shelf=frontier/haystack#04-agent-loop @e50a5bce6439 事实=State 是带 schema 的键值容器,每个键有 type 与合并 handler:list 默认 merge_lists 追加、其余默认 replace_values 覆盖)

这解释了消息为什么能"累积":消息键被注册成列表 + 追加规则,每轮写入是追加而不是覆盖。需要整段替换时显式传"覆盖"。

这个设计让"往历史里加东西"和"把历史整段换掉"用同一个接口表达,不需要两套 API。

钩子:五个挂点

运行前 / 调模型前 / 工具前 / 工具后 / 运行后。人工确认、日志、护栏都从这里插。

一个很实的细节: 执行工具前,它从状态里重读一遍待执行的调用——因为钩子(比如人工确认)可能刚刚改过它们。 (依据:前沿库 · Haystack · Haystack Agent —— 引擎之上手写的 LLM↔工具循环 —— 执行工具前从 State 里重读待执行调用,因为 BEFORE_TOOL 钩子(如 ConfirmationHook 人工确认)可能刚改过它们)

这条是"钩子能改东西"这件事的必然要求,漏了就会用到过期的调用列表。

它没回答什么

  • 历史怎么压——这一章不管长对话。
  • 不用原生工具调用怎么办——它要求生成器支持工具参数。
  • 系统提示怎么拼——没讲。

坑与代价

  • 它的断点/快照机制被上游整个移除了。 早期版本支持"在调模型或工具处断点、快照恢复",现在没了;官方文档页也一并撤下。Agent 层面的"停下来看看"现在只能用钩子或退出条件表达。 (依据:前沿库 · Haystack · Haystack Agent —— 引擎之上手写的 LLM↔工具循环 —— 早期的 AgentBreakpoint/ToolBreakpoint/AgentSnapshot 已在上游 PR #11202 移除,官方文档页也撤下;Agent 层的暂停现在用钩子或 exit_conditions + max_agent_steps 表达)

    这是一条重要的阴性信号:一个成熟项目试过"agent 级断点"然后把它删了。 我们要做"中途插手"时,应该先去看它为什么删——很可能是"快照要存的东西比想象中多"。

  • 工具执行用固定四个线程的池。 并发度是写死的,不随工具数量变。
  • 依赖分析的前提是"工具声明了自己读写哪些键"。 没声明的工具就退化成"无依赖",可以乱并行——这个机制的准确性取决于声明的诚实度。