从字节跳动 DeerFlow 源码看 Agent 平台设计(三):五个核心中间件深度解析

系列导航

摘要

中间件模式是 DeerFlow 架构中最具设计深度的部分。本文选取五个代表性中间件------SandboxMiddleware、SummarizationMiddleware、MemoryMiddleware、ClarificationMiddleware 和 SafetyFinishReasonMiddleware------逐一分析其设计动机、实现机制和关键决策,展示中间件模式在 Agent 系统中如何解耦横切关注点。


一、中间件模式的价值

Agent 的核心逻辑是 ReAct 循环(推理→行动→观察),但生产级 Agent 还需要处理大量横切关注点:资源管理、安全防护、上下文压缩、记忆持久化、人机交互控制等。

将这些关注点直接写入核心逻辑会导致代码耦合度急剧上升。DeerFlow 借鉴 Web 框架的中间件模式,将每个关注点封装为独立的 AgentMiddleware,通过 hook 机制挂载到 Agent 执行流程的特定节点。

每个中间件可以选择性实现以下 hook:

Hook 触发时机 执行频率
before_agent Agent run 开始前 一次/run
after_agent Agent run 结束后 一次/run
before_model 每次模型调用前 每轮循环
after_model 每次模型调用后 每轮循环
wrap_model_call 包装模型调用过程 每轮循环
wrap_tool_call 包装工具执行过程 每次工具调用

二、SandboxMiddleware --- 执行环境隔离

2.1 设计动机

Agent 需要执行代码(bash 命令、Python 脚本等)来完成任务。如果代码直接在宿主机上运行,存在严重安全隐患------模型生成的代码可能包含破坏性操作。SandboxMiddleware 的职责是在 Agent 执行前分配隔离环境,执行后回收资源。

2.2 实现机制

python 复制代码
class SandboxMiddleware(AgentMiddleware[SandboxMiddlewareState]):
    def __init__(self, lazy_init: bool = True):
        self._lazy_init = lazy_init

    def before_agent(self, state, runtime):
        if self._lazy_init:
            return  # 延迟到首次工具调用时分配
        thread_id = runtime.context.get("thread_id")
        sandbox_id = get_sandbox_provider().acquire(thread_id)
        return {"sandbox": {"sandbox_id": sandbox_id}}

    def after_agent(self, state, runtime):
        sandbox = state.get("sandbox")
        if sandbox is not None:
            get_sandbox_provider().release(sandbox["sandbox_id"])

2.3 关键设计决策

延迟初始化(lazy_init=True

默认配置下,沙箱不在 before_agent 时立即分配,而是延迟到第一次工具调用时。原因是大量对话请求(纯文本问答)根本不需要执行代码,预分配沙箱(尤其是 Docker 容器)会造成不必要的资源消耗和延迟。

Provider 抽象

沙箱的具体实现通过 SandboxProvider 抽象接口解耦:

Provider 场景 隔离级别
LocalSandboxProvider 本地开发 目录级隔离,bash 默认禁用
AioSandboxProvider 生产部署 Docker 容器级隔离
AioSandboxProvider + Provisioner K8s 部署 Pod 级隔离

Provider 通过 config.yamlsandbox.use 字段配置,运行时反射加载。

虚拟路径映射

Agent 看到的文件路径是虚拟路径,与宿主机真实路径完全隔离:

Agent 视角 宿主机实际路径
/mnt/user-data/workspace .deer-flow/threads/{thread_id}/user-data/workspace
/mnt/user-data/uploads .deer-flow/threads/{thread_id}/user-data/uploads
/mnt/skills skills/

所有文件操作经过路径映射转换,Agent 无法感知也无法访问映射范围外的宿主机目录。


三、SummarizationMiddleware --- 上下文窗口管理

3.1 设计动机

LLM 存在上下文窗口限制(4K-200K token)。在长对话中,历史消息持续积累,最终将超出窗口容量,导致调用失败或关键信息丢失。SummarizationMiddleware 在模型调用前检测容量状态,必要时将旧消息压缩为摘要。

3.2 实现机制

该中间件工作在 before_model hook:

python 复制代码
class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
    def before_model(self, state, runtime):
        messages = state["messages"]
        total_tokens = self.token_counter(messages)
        
        if not self._should_summarize(messages, total_tokens):
            return None
        
        cutoff_index = self._determine_cutoff_index(messages)
        to_summarize, preserved = self._partition_with_skill_rescue(messages, cutoff_index)
        
        self._fire_hooks(to_summarize, preserved, runtime)
        summary = self._create_summary(to_summarize)
        
        return {
            "messages": [
                RemoveMessage(id=REMOVE_ALL_MESSAGES),
                HumanMessage(content=f"Summary: {summary}", name="summary"),
                *preserved
            ]
        }

3.3 Token 计数的时序问题

一个常见问题是:模型 API 返回的 usage.prompt_tokens 是精确的 token 数,为何不直接使用?

答案在于时序矛盾 :SummarizationMiddleware 工作在 before_model------此时尚未调用模型,无法获取本次调用的 usage。上一次调用的 usage 也不可靠,因为中间可能新增了大量 ToolMessage(如文件内容、网页内容)。

因此 DeerFlow 使用近似计数(基于字符数估算),精度虽不如 tokenizer 精确,但对"是否需要摘要"的阈值判断而言足够:

  • Anthropic 模型:约 3.3 字符/token
  • 其他模型:LangChain 默认估算

3.4 Skill Rescue 机制

这是 DeerFlow 在标准 SummarizationMiddleware 上的重要扩展。

问题 :Agent 在对话早期通过 read_file("/mnt/skills/pdf-processing/SKILL.md") 加载了技能内容。如果这些 ToolMessage 被摘要压缩,Agent 将"遗忘"该技能的具体指令。

解决方案:在分区时扫描即将被摘要的消息,识别出技能文件读取的 tool_call + ToolMessage 对,将最近使用的技能"抢救"到保留区:

python 复制代码
def _partition_with_skill_rescue(self, messages, cutoff_index):
    to_summarize, preserved = self._partition_messages(messages, cutoff_index)
    
    bundles = self._find_skill_bundles(to_summarize, self._skills_container_path)
    rescue_bundles = self._select_bundles_to_rescue(bundles)
    
    # 将被抢救的技能从摘要区移到保留区
    ...
    return remaining, rescued + preserved

抢救预算通过三个参数控制:

  • preserve_recent_skill_count: 5 --- 最多保留 5 个技能
  • preserve_recent_skill_tokens: 25000 --- 技能保留总 token 预算
  • preserve_recent_skill_tokens_per_skill: 5000 --- 单个技能 token 上限

3.5 Summarization Hook

在摘要执行前触发 before_summarization 钩子,允许其他组件在消息被删除前做处理。DeerFlow 利用该钩子将即将被摘要的消息先写入长期记忆memory_flush_hook),确保信息不会彻底丢失。


四、MemoryMiddleware --- 跨对话长期记忆

4.1 设计动机

Summarization 解决的是单次对话内的上下文管理。但用户期望 Agent 能跨对话记住重要信息------例如用户偏好、项目技术栈、之前的工作上下文等。MemoryMiddleware 负责从对话中异步提取记忆并持久化存储。

4.2 实现机制

该中间件工作在 after_agent hook,且不修改当前对话状态

python 复制代码
class MemoryMiddleware(AgentMiddleware):
    def after_agent(self, state, runtime):
        if not config.enabled:
            return None
        
        messages = state["messages"]
        filtered = filter_messages_for_memory(messages)
        
        correction_detected = detect_correction(filtered)
        reinforcement_detected = detect_reinforcement(filtered)
        
        queue = get_memory_queue()
        queue.add(
            thread_id=thread_id,
            messages=filtered,
            correction_detected=correction_detected,
            reinforcement_detected=reinforcement_detected,
        )
        return None  # 不修改状态

4.3 消息过滤

并非所有消息都有记忆价值。filter_messages_for_memory() 只保留用户输入和最终助手回复,过滤掉中间的工具调用细节(ToolMessage、中间 AIMessage 等)。

4.4 信号检测

记忆系统会检测两种特殊信号:

  • Correction(纠错):用户纠正了 Agent 的做法。例如 "不要用 MySQL,我们用的是 PostgreSQL"。这类纠错应高优先级写入记忆。
  • Reinforcement(强化):用户表扬了某个做法。例如 "对,就是这样做的"。正向反馈也应被记录。

4.5 防抖队列

记忆更新需要调用 LLM 做提取(成本较高)。DeerFlow 使用防抖队列(Debounce Queue)批量处理:

css 复制代码
用户连续发消息:
  msg1 → 入队 → 重置计时器(debounce_seconds)
  msg2 → 替换队列中同 thread_id 的记录 → 重置计时器
  msg3 → 替换 → 重置计时器
  ... debounce_seconds 秒无新消息 ...
  → _process_queue() 触发
  → MemoryUpdater 调用 LLM 提取记忆

关键设计点:

  • 替换而非追加:同一 thread_id 的多次入队只保留最新消息列表,避免重复处理
  • daemon 线程:Timer 线程设为 daemon,进程退出时不阻塞
  • user_id 捕获:在入队时(请求线程内)捕获 user_id,因为 Timer 线程不继承 ContextVar

4.6 记忆注入(读取端)

记忆的注入不在 MemoryMiddleware 中,而在 DynamicContextMiddleware。它将长期记忆内容注入第一条 HumanMessage 中:

xml 复制代码
<system-reminder>
Current date: 2026-06-13
User preferences: prefers TypeScript, uses PostgreSQL, project uses Next.js
</system-reminder>

为何不放入系统提示词?因为系统提示词应保持静态,以便 LLM 推理服务的 KV Cache 前缀缓存生效。动态内容放在用户消息中,系统提示词永远不变,缓存命中率最大化。


五、ClarificationMiddleware --- 澄清中断机制

5.1 设计动机

Agent 并非总应该猜测并执行------当信息不足、需求模糊或操作有风险时,正确的行为是暂停执行并向用户提问。ClarificationMiddleware 实现了这一"主动提问→等待回复→继续执行"的控制流。

5.2 实现机制

该中间件工作在 wrap_tool_call hook,拦截特定工具调用:

python 复制代码
class ClarificationMiddleware(AgentMiddleware):
    def wrap_tool_call(self, request, handler):
        if request.tool_call.get("name") != "ask_clarification":
            return handler(request)  # 非澄清调用,正常执行
        return self._handle_clarification(request)
    
    def _handle_clarification(self, request):
        args = request.tool_call.get("args", {})
        formatted = self._format_clarification_message(args)
        
        return Command(
            update={"messages": [ToolMessage(content=formatted, ...)]},
            goto=END,  # 中断图执行
        )

5.3 为何是中间件而非普通工具

如果 ask_clarification 作为普通工具实现,LangGraph 会执行它并将结果返回模型,模型继续推理下一步。但澄清的语义是完全暂停 --- 等待人类回复后才能继续。

Command(goto=END) 使 LangGraph 的 StateGraph 正常终止当前 run。结合 Checkpointer 持久化机制(详见第四篇),用户回复时可从断点恢复继续执行。

5.4 澄清类型

系统定义了五种澄清类型,每种有对应的视觉标识:

python 复制代码
type_icons = {
    "missing_info": "❓",           # 缺少关键信息
    "ambiguous_requirement": "🤔",  # 需求表述模糊
    "approach_choice": "🔀",        # 需要用户在多个方案中选择
    "risk_confirmation": "⚠️",      # 高风险操作需要确认
    "suggestion": "💡",             # 建议性提问
}

格式化后的输出示例:

markdown 复制代码
🤔 需求中提到"优化性能",存在多种方向:

  1. 减少 API 调用次数
  2. 添加缓存层
  3. 优化数据库查询

请选择倾向的方向。

5.5 位置约束

ClarificationMiddleware 必须在中间件列表的最后位置 。原因:LangChain 的 wrap_tool_call 按列表顺序执行,最后注册的中间件最先拦截。ClarificationMiddleware 作为最终拦截者,确保澄清请求不会被其他中间件(如 ToolErrorHandlingMiddleware)错误处理。


六、SafetyFinishReasonMiddleware --- 安全过滤防护

6.1 设计动机

LLM 提供商(OpenAI、Anthropic、Google)内置了内容安全过滤器。当模型输出触发安全规则时,提供商会中途截断响应 ------但仍可能返回部分已生成的 tool_calls

问题场景:

python 复制代码
模型正在生成:
  "好的,写入文件..."
  tool_call: write_file(content="export const config = {
    password: 'se    ← 安全过滤器触发,截断!

返回:
  finish_reason: "content_filter"
  tool_calls: [{name: "write_file", args: {content: "export const config = {..."}}]

LangChain 的 ToolNode 会尝试执行这个参数不完整的工具调用------写入一个被截断的文件。Agent 随后发现文件不完整,尝试修复,再次触发安全过滤,形成死循环。

6.2 实现机制

该中间件工作在 after_model hook:

python 复制代码
class SafetyFinishReasonMiddleware(AgentMiddleware):
    def after_model(self, state, runtime):
        last = state["messages"][-1]
        
        if not isinstance(last, AIMessage):
            return None
        if not last.tool_calls:
            return None  # 无 tool_calls 的截断是无害的
        
        termination = self._detect(last)
        if termination is None:
            return None
        
        patched = self._build_suppressed_message(last, termination)
        return {"messages": [patched]}

6.3 多提供商检测器

不同提供商的安全终止信号不同,DeerFlow 通过检测器链适配:

提供商 信号字段 终止值
OpenAI finish_reason "content_filter"
Anthropic stop_reason "refusal"
Google Gemini finish_reason "SAFETY"

检测器可通过配置扩展:

yaml 复制代码
safety_finish_reason:
  enabled: true
  detectors:
    - use: "deerflow.agents.middlewares.safety_termination_detectors:OpenAIContentFilterDetector"
    - use: "deerflow.agents.middlewares.safety_termination_detectors:AnthropicRefusalDetector"

6.4 抑制策略

检测到安全终止后的处理:

python 复制代码
def _build_suppressed_message(self, message, termination):
    explanation = (
        "The model provider stopped this response with a safety-related signal... "
        "Any tool calls produced in this turn were suppressed..."
    )
    # 清除所有 tool_calls
    cleared = clone_ai_message_with_tool_calls(message, [])
    # 附加安全元数据(审计用)
    cleared.additional_kwargs["safety_termination"] = {
        "detector": termination.detector,
        "suppressed_tool_call_count": ...,
        "suppressed_tool_call_names": [...],
    }
    return cleared

处理结果:

  1. 截断的 tool_calls 被清除(不执行)
  2. 用户看到解释消息
  3. 审计日志记录事件详情
  4. SSE 流发出 safety_termination 事件通知前端

6.5 与 LoopDetectionMiddleware 的协作

两者都在 after_model 工作。注册顺序:[..., LoopDetection, SafetyFinishReason, ...]

LangChain 的 after_model反序执行(最后注册的最先执行)。因此:

复制代码
SafetyFinishReason.after_model  ← 先执行,清除截断的 tool_calls
LoopDetection.after_model       ← 后执行,看到的是干净消息

如果顺序颠倒,LoopDetection 会把安全截断误判为"循环"并发出虚假警告。

6.6 审计事件持久化

中间件通过 RunJournal 将安全事件写入持久化存储:

python 复制代码
journal.record_middleware(
    tag="safety_termination",
    name=type(self).__name__,
    hook="after_model",
    action="suppress_tool_calls",
    changes={...}
)

工具的 arguments 不被记录------这些正是被提供商过滤的内容,持久化它们有悖安全过滤的初衷。仅记录工具名称、数量和事件 ID,足以满足审计需求。


七、对比总结

中间件 Hook 修改状态 核心模式
SandboxMiddleware before_agent / after_agent 是(sandbox_id) 资源获取/释放对称
SummarizationMiddleware before_model 是(替换消息历史) 输入预处理
MemoryMiddleware after_agent 否(异步副作用) 后台任务触发
ClarificationMiddleware wrap_tool_call 是(中断执行) 控制流改变
SafetyFinishReasonMiddleware after_model 是(清除 tool_calls) 输出后处理/防护

这五个中间件代表了中间件模式的五种典型应用场景,覆盖了 Agent 系统中除核心推理外的大部分工程化需求。


下一篇预告

第四篇将分析 Agent 的生命周期与状态管理:before_agentbefore_model 的精确语义区分、Thread/Run/Loop iteration 三层概念、Clarification 中断后通过 Checkpoint 恢复的完整流程,以及 Docker-compose 部署下 AioSandboxProvider 的 DooD 方案。

相关推荐
雪隐1 小时前
AI股票小助手09-结果展示
人工智能·后端
沪漂阿龙1 小时前
RAG 是什么?为什么大模型需要外挂知识库?
人工智能·架构·langchain
蝎子莱莱爱打怪1 小时前
AI时代,戒骄戒躁,静下心来,想清楚,再出发!
人工智能·程序员
数字游民95271 小时前
PDF批量转Markdown工具:我用AI做了一个本地桌面版,也顺手想了想AI工具怎么落地
人工智能·ai·pdf·aigc·自媒体·数字游民9527
KaMeidebaby1 小时前
卡梅德生物技术快报|biotin 生物素标记抗体全流程
前端·人工智能·算法·数据挖掘·数据分析
聂二AI落地内参1 小时前
从 AI 幻觉到重试:体检报告 AI 的几个工程坑
人工智能
阳明山水1 小时前
自下而上 vs 自上而下 vs 最优组合预测策略解析
大数据·人工智能·深度学习·算法·机器学习
FPC_小西1 小时前
LDO 低压差线性稳压器 拆解电源稳压核心原理
人工智能·单片机·嵌入式硬件·集成学习·pcb工艺·hdi高密度互联
长空任鸟飞_阿康2 小时前
RAG 文档摄入全链路,从原理到生产落地
vue.js·人工智能·python