高级 📋 6 个步骤 第 482 / 484 篇

Haystack 3.2.0 实操:给长流程 Agent 加上下文预算,摘要压缩与预算熔断双保险

Haystack 3.2.0 实操:破坏性变更自查、长流程 Agent 基线、SummarizationCompactor 压缩旧步骤、TokenBudgetHook 预算熔断、回归脚本与 Pipeline 装配新写法。

2026.09.28· 20 分钟阅读· 约 3082 字· 📉 Haystack / 🧮 上下文预算

跑得久的多步 Agent 都躲不开同一道算术题:每走一步,历史消息就长一截。到了第几十步,上下文窗口要么被历史塞满报错,要么你为了省 token 直接把旧消息砍掉——然后 Agent 开始重复劳动、前后矛盾,因为它「忘了」自己刚查过什么。Haystack 3.2.0 针对这个问题给了一对组合拳:SummarizationCompactor(用 LLM 把久远的步骤摘要成紧凑历史,而不是直接丢弃,min_keep_steps 保证最近的步骤原封不动)与 TokenBudgetHook(token 预算花完就在下次模型调用之前干净停车,把已收集的消息和 exit_reason 一并交还)。本篇带你把一个会失控的长流程 Agent 改造成「自动压缩 + 预算熔断」双保险,顺带处理 3.2.0 带来的两个破坏性变更。

🎯 适合人群:已经在用 Haystack 写多步 Agent(检索、工具循环、长任务编排)的 Python 开发者。需要 Python 3.10+ 环境、一个模型 API Key;支持的解释器区间以官方安装文档为准。

先理解:丢历史、压历史、掐预算是三件事

很多人把「上下文管理」混为一谈,其实是三个不同层次的问题:丢历史最粗暴——旧消息直接截断,省了 token 但丢了事实,Agent 转头重查一遍、甚至拿旧结论当新结论;压历史是 3.2.0 的 SummarizationCompactor 思路——用一次便宜的 LLM 调用把久远步骤蒸馏成摘要,最近的 min_keep_steps 步原样保留,保证「刚才干了什么」永远精确;掐预算是 TokenBudgetHook 思路——给整次运行设一个 token 上限,花完就在下一次模型调用之前停车,而不是跑到一半报错崩掉,此时已收集的消息和 exit_reason 都还在,任务可以带着现场交给人工或下轮续跑。三者组合起来才是完整的长流程护栏:压历史负责「跑得动」,掐预算负责「停得下」。

版本敏感,先对齐再动手。Haystack 3.2.0 刚发布,SummarizationCompactor 官方标注为实验特性;本篇涉及的 import 路径与参数名在你安装的版本里可能有出入,动手前对照官方 release notes 与文档确认,别照抄硬跑。另外 3.2.0 有两个破坏性变更(Step 1 详述),从 2.x 直接升级的项目先过自查再谈新特性。

Step 1:升级 3.2.0,先过破坏性变更自查

1 升级依赖,两项自查跑在前面
# 在虚拟环境里升级
pip install -U "haystack-ai>=3.2.0"

# 确认版本到位
python -c "import haystack; print(haystack.__version__)"

升级后先做两项自查,都是 3.2.0 的破坏性变更:其一,Toolset 之间用 + 号组合的写法被彻底移除——如果项目里有 toolset_a + toolset_b,改成把多个 Toolset 放进列表传入;以前用 + 拼过 Toolset 再序列化过的管道,升级后会直接加载失败,必须改写成列表形态重新序列化。其二,序列化产物里带 Jinja custom_filters 的 OutputAdapter 或 ConditionalRouter 组件,现在必须用 Pipeline.load(..., unsafe=True) 才能加载——这是有意的安全收紧,改动只影响加载环节,不影响运行。

# 全库搜这两类旧写法,命中即登记改造清单
# Linux / macOS
grep -rn "toolset_a + toolset_b\|custom_filters" --include="*.py" .
# 改法一:+ 号组合 → 列表
agent = Agent(chat_generator=llm, tools=[toolset_a, toolset_b, search_tool])
# 改法二:带自定义 Jinja 过滤器的序列化管道 → 显式 unsafe 加载
pipe = Pipeline.load("pipeline.yaml", unsafe=True)  # 仅限可信来源的文件
💡 判断优先级:先把 + 组合和 unsafe 加载这两项改到回归全绿,再上新特性。破坏性变更没清干净就叠新功能,出问题时你会分不清是新 API 的锅还是迁移的锅。

Step 2:起一个会膨胀的长流程 Agent 基线

2 先让它「病发」,再谈治疗
from haystack.components.agents import Agent
from haystack.components.generators.chat import OpenAIChatGenerator
from haystack.dataclasses import ChatMessage
from haystack.tools import tool

@tool
def search_orders(query: str) -> str:
    """在订单库里检索与 query 相关的订单记录,返回JSON文本。"""
    # 换成你的真实数据源
    return '[{"order_id": "A1001", "status": "shipped"}]'

@tool
def get_refund_policy(topic: str) -> str:
    """查询售后/退款政策条目。"""
    return "7天内未签收可全额退款,需提供订单号。"

agent = Agent(
    chat_generator=OpenAIChatGenerator(model="gpt-4o-mini"),
    tools=[search_orders, get_refund_policy],
    system_prompt="你是售后助理。逐条核对订单后再下结论,查不到就说明原因。",
)

result = agent.run({"messages": [ChatMessage.from_user(
    "核对这20笔订单的退款资格,逐笔给依据并汇总成清单。")]})
print(len(result["messages"]))  # 观察历史长度随步数增长

给 Agent 派一个天然需要十几二十步的任务(逐笔核对 20 笔订单),然后盯着 result["messages"] 的长度看历史怎么滚雪球:每一步的工具调用、工具结果、中间推理都整段留在历史里。跑到任务后段,单次请求的 token 数可能是开头的好几倍——费用在涨,而模型对开头信息的注意力在稀释。这就是基线病灶,后面两步分别治它。

💡 预期效果:任务能正常跑完、结论正确,但把 messages 逐条打出来看,会发现大量早期工具结果是重复且久远的信息——它们就是压缩器的原料。

Step 3:接 SummarizationCompactor,旧步骤蒸馏成摘要

3 用一次便宜的调用换一段紧凑的历史
# 模块路径与参数名以 3.2.0 官方文档为准(下同)
from haystack.components.agents import SummarizationCompactor

compactor = SummarizationCompactor(
    generator=OpenAIChatGenerator(model="gpt-4o-mini"),  # 摘要可用更便宜的模型
    min_keep_steps=3,   # 最近 3 步原样保留,绝不压缩
)

# 挂载方式与参数形态随版本而定:把 compactor 配置进 Agent 的
# 上下文管理入口后重跑 Step 2 的任务,对比压缩前后的 messages

SummarizationCompactor 的工作方式:当运行历史变长时,用一个 LLM 调用把久远的步骤蒸馏成一段摘要文本,替换掉原始的多条消息;min_keep_steps 指定的最近若干步保持原样——这很重要,因为模型对「刚才发生了什么」的精确记忆直接影响下一步决策,摘要只该发生在久远历史上。重跑 Step 2 的任务后对比:任务结论不变形,历史长度显著下降,后段请求的 token 成本随之回落。

调参经验:min_keep_steps 不要设太小——压得太激进,模型会忘记自己刚调过什么工具,开始重复检索;也别太大,压了个寂寞。对逐条核对类任务,保留最近 2-4 步是常见起点,按「压缩后结论是否变形」来微调。摘要模型选便宜档即可,它做的是信息蒸馏而不是决策。

摘要是有损压缩。被蒸馏掉的原始工具结果无法从摘要里复原——如果下游要审计「Agent 当时查到了什么原始数据」,请在应用层先把关键工具结果落库再进 Agent,别指望历史消息当审计日志。

Step 4:接 TokenBudgetHook,预算花完干净停车

4 与其半路崩溃,不如按预算体面收场
from haystack.components.agents import TokenBudgetHook

budget_hook = TokenBudgetHook(
    budget=120_000,  # 整次运行的 token 预算上限(示例值)
)

# 把 hook 挂进 Agent 运行(挂载入口以官方文档为准),触发后的
# 返回结果里带着:已收集的完整 messages + exit_reason
result = agent.run({"messages": [ChatMessage.from_user("核对这50笔订单……")]})
# 读取停车原因与现场(字段名以文档为准)
print(result.get("exit_reason", "completed"))

TokenBudgetHook 解决的是另一半问题:压缩让 Agent 跑得动,但一个被脏数据逼进循环的 Agent 仍可能无限制地烧下去。这个 hook 持续记账,预算耗尽时在下一次模型调用之前让运行干净停止——不是抛异常崩掉,而是带着已收集的全部消息与 exit_reason 正常返回。这对调度系统极友好:外层代码检查 exit_reason,发现是预算触顶就把现场转人工、或换个提示让它带着已有进展续跑,而不是从零重来。

预算怎么估:跑三遍代表性任务,取「正常完成」的平均消耗乘 1.3 作冗余系数,得出默认预算;再设一个硬顶(比如平均值的 3 倍)作为熔断线。预算线划太紧,正常任务被误杀;划太松,失控任务照样烧钱——用实测数据定线,别拍脑袋。

💡 预算触顶不是失败,是信号。exit_reason 频繁报预算停车时,先看轨迹找原因:是任务真的超出预算,还是 Agent 在反复调用同一工具?前者加预算或改拆任务,后者去修提示词和工具描述。

Step 5:双保险组合与上线前回归

5 压历史 + 掐预算 + 回归脚本,一起上
# 两个机制各管一层,同时挂载(挂载入口以官方文档为准)
# compactor 管单次运行的历史膨胀
# budget_hook 管整次运行的资源上限
#
# 上线前写一个最小回归脚本:
def test_long_task_stops_cleanly():
    result = agent.run({"messages": [ChatMessage.from_user(TASK_50_ORDERS)]})
    msgs = result["messages"]
    # 断言:要么正常完成,要么预算停车——不允许异常崩溃
    assert result.get("exit_reason", "completed") in ("completed", "budget_exhausted")
    # 断言:压缩生效后历史长度低于未压缩基线
    assert len(msgs) < BASELINE_LEN
    # 断言:最近步骤未被压缩(保留完整工具结果)
    assert TOOL_RESULT_MARK in msgs[-3].text

上线前用三个断言把行为锁死:停得下(任何结局都是体面返回)、压得动(历史长度确实低于基线)、压不坏(最近步骤完整保留、最终结论与未压缩版本一致)。回归脚本进 CI,每次升级 haystack-ai 都跑一遍——实验特性的 API 在小版本间仍可能调整,回归脚本是你的哨兵。

unsafe=True 只对可信文件使用。Step 1 里那个 Pipeline.load(..., unsafe=True) 允许加载带自定义 Jinja 过滤器的序列化管道,也意味着加载来历不明的 yaml 时可能执行其中的过滤逻辑。只对你自己导出、走代码评审的管道文件开 unsafe;外部下载的示例管道先读内容再决定。

Step 6:顺带升级——Pipeline 装配的新写法

6 add_components / connect_many,少写一半样板
from haystack import Pipeline

pipe = Pipeline()
# 3.2.0:一次加多个组件、一次连多条边,且可链式调用
pipe.add_components(retriever, embedder, ranker) \
    .connect_many([(embedder, retriever), (retriever, ranker)])

# 旧写法仍然有效,但新写法在动态装配场景(按配置拼管道)里
# 能把十几次 add_component / connect 调用收拢成两行

这次发布还带了 add_components() 与 connect_many()——支持一次传入多个组件或多条连接,并且链式调用。对「按配置文件动态拼管道」的团队最实用:原来一段十几行的装配循环,现在收拢成两行,出错面也更小。存量代码不用改,旧写法兼容;新代码建议直接用新写法。

💡 升级清单收尾:跑一遍全量回归 → 确认 sitemap/文档里引用的版本号同步更新 → 把 3.2.0 的两个破坏性变更写进团队 README,防止新同事从旧教程里把 + 组合学回来。

常见问题速查

你遇到的现象大概率原因 & 解决
升级后管道加载直接报错命中破坏性变更:找 + 组合的 Toolset 改列表;带 custom_filters 的序列化产物用 unsafe=True 加载
SummarizationCompactor import 报错实验特性,模块路径随版本变动,对照你安装版本的官方文档
压缩后 Agent 开始重复检索/结论变形min_keep_steps 太小。调大保留步数,或检查摘要模型是否太弱
预算经常触顶但任务并没跑偏预算线定太紧。用实测平均消耗 × 1.3 重新定线
预算从不触顶但费用很高压缩没生效。确认 compactor 真的挂载成功,对比压缩前后 messages 长度
想要审计原始工具结果摘要有损。应用层先把关键工具结果落库,别拿历史消息当审计日志
← 返回教程中心