系列导航
摘要
中间件模式是 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.yaml 的 sandbox.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
处理结果:
- 截断的 tool_calls 被清除(不执行)
- 用户看到解释消息
- 审计日志记录事件详情
- 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_agent 和 before_model 的精确语义区分、Thread/Run/Loop iteration 三层概念、Clarification 中断后通过 Checkpoint 恢复的完整流程,以及 Docker-compose 部署下 AioSandboxProvider 的 DooD 方案。