Lead Agent 中间件
基于
_build_middlewares()函数(backend/packages/harness/deerflow/agents/lead_agent/agent.py:269-363)
一、中间件组装总览
中间件链分为 两个阶段 组装:
- 阶段一 :
build_lead_runtime_middlewares()------ 基础设施层中间件(所有 Agent 共享) - 阶段二 :
_build_middlewares()中追加 ------ Lead Agent 专属业务中间件
重要规则 :LangChain 的
after_model钩子按 逆序 分发------最后 append 的中间件在after_model阶段 最先执行。因此 ClarificationMiddleware 必须最后 append,SafetyFinishReasonMiddleware 次之。
二、中间件详细顺序、功能与实现
阶段一:基础设施层(build_lead_runtime_middlewares)
#1 ThreadDataMiddleware
| 项目 | 说明 |
|---|---|
| 文件 | middlewares/thread_data_middleware.py |
| 功能 | 为每个线程创建数据目录结构:threads/{thread_id}/user-data/{workspace,uploads,outputs} |
| 生命周期钩子 | before_agent |
核心流程:
runtime.context → thread_id → Paths 计算目录路径 → 写入 state.thread_data
实现细节:
- 路径计算 :通过
Paths类解析基础目录,结合thread_id和user_id计算三个目录路径:sandbox_work_dir(thread_id, user_id)→ workspace 目录sandbox_uploads_dir(thread_id, user_id)→ uploads 目录sandbox_outputs_dir(thread_id, user_id)→ outputs 目录
- 懒加载模式 (
lazy_init=True):仅计算路径,不调用ensure_thread_dirs(),目录在首次使用时按需创建 - 消息增强 :为最后一条
HumanMessage添加run_id和timestamp到additional_kwargs,并设置name="user-input" - 用户隔离 :通过
get_effective_user_id()获取当前用户 ID,实现多用户场景下的路径隔离
#2 UploadsMiddleware
| 项目 | 说明 |
|---|---|
| 文件 | middlewares/uploads_middleware.py |
| 功能 | 将用户上传文件的信息(文件名、路径、文档大纲/预览)注入到 Agent 上下文中 |
| 生命周期钩子 | before_agent |
核心流程:
最后一条 HumanMessage → additional_kwargs.files → 解析文件元数据 → 提取大纲/预览 → 构建 <uploaded_files> 消息 → 前置到用户消息
实现细节:
- 新文件提取 :从
HumanMessage.additional_kwargs.files读取前端上传的文件元数据(filename、size、path、status),验证文件名合法性(Path(filename).name == filename防止路径穿越) - 历史文件扫描 :遍历线程 uploads 目录(
uploads_dir.iterdir()),排除新文件后收集历史文件 - 文档大纲提取 :
_extract_outline_for_file()查找伴生.md文件,通过extract_outline()提取{title, line}结构;若大纲为空则读取前 5 行非空内容作为预览 - 消息构建 :
_create_files_message()生成<uploaded_files>格式化消息,包含文件列表、大纲、操作指南;新文件和历史文件分区展示 - 多模态兼容 :对
list类型 content(含图像等多模态块),将文件消息作为首个 text block 插入;保留原始additional_kwargs供前端读取
#3 SandboxMiddleware
| 项目 | 说明 |
|---|---|
| 文件 | sandbox/middleware.py |
| 功能 | 为 Agent 创建和管理沙箱执行环境 |
| 生命周期钩子 | before_agent、after_agent、wrap_tool_call |
核心流程:
thread_id → SandboxProvider.acquire() → sandbox_id → state.sandbox
...工具调用使用沙箱...
state.sandbox → SandboxProvider.release(sandbox_id)
实现细节:
- 沙箱获取 :
_acquire_sandbox(thread_id)调用get_sandbox_provider().acquire(thread_id)获取沙箱 ID;异步版本使用acquire_async() - 懒加载策略 :
lazy_init=True时before_agent直接返回super(),延迟到wrap_tool_call中首次工具调用时才获取沙箱 - 双来源释放 :
after_agent优先从state.sandbox取 sandbox_id,其次从runtime.context.sandbox_id取(兼容上下文直传场景) - 异步释放 :
_release_sandbox_async()通过asyncio.to_thread()将同步release()包装为异步操作,避免阻塞事件循环 - 生命周期 :沙箱在同一线程内跨轮次复用,不主动释放(避免重建开销),仅在应用关闭时通过
SandboxProvider.shutdown()统一清理
#4 DanglingToolCallMiddleware
| 项目 | 说明 |
|---|---|
| 文件 | middlewares/dangling_tool_call_middleware.py |
| 功能 | 修复消息历史中的"悬空工具调用"------AIMessage 包含 tool_calls 但缺少对应 ToolMessage 的情况 |
| 生命周期钩子 | wrap_model_call |
核心流程:
request.messages → 扫描 AI+Tool 消息 → 检测悬空 tool_calls → 插入合成 ToolMessage → request.override(messages)
实现细节:
- 工具调用归一化 :
_message_tool_calls()从三个来源收集工具调用:msg.tool_calls:结构化字段(标准路径)msg.additional_kwargs.tool_calls:原始 provider payload(含function.name/function.arguments嵌套格式)msg.invalid_tool_calls:解析失败的调用(标记"invalid": True)
- 修补策略 :
_build_patched_messages()使用两遍扫描:- 第一遍:建立
tool_call_id → deque[ToolMessage]索引 - 第二遍:遍历消息,对每条 AIMessage 的每个 tool_call 检查是否有对应 ToolMessage;缺失则插入合成错误消息
- 第一遍:建立
- 合成消息内容 :
_synthetic_tool_message_content()区分 invalid(参数无效)和 interrupted(被中断)两种情况 - 位置保持 :使用
wrap_model_call(非before_model)确保修补消息插入到 AIMessage 之后 正确位置,而非通过add_messagesreducer 追加到末尾
#5 LLMErrorHandlingMiddleware
| 项目 | 说明 |
|---|---|
| 文件 | middlewares/llm_error_handling_middleware.py |
| 功能 | 对 LLM 调用中的瞬态错误进行重试/退避,并向用户展示友好的降级消息 |
| 生命周期钩子 | wrap_model_call |
核心流程:
检查熔断器 → handler(request) → 成功: record_success / 失败: classify_error → 可重试: 退避重试 / 不可重试: 返回用户消息
实现细节:
- 熔断器(Circuit Breaker) :三态模型
- Closed(正常):失败计数 < 阈值,正常放行
- Open (熔断):失败计数 >= 阈值,快速失败返回 AIMessage;持续
recovery_timeout_sec秒后转 Half-Open - Half-Open(探测):放行一次请求作为探针,成功则转 Closed,失败则转 Open
- 使用
threading.Lock保证线程安全;配置来自app_config.circuit_breaker
- 错误分类 :
_classify_error()返回(retriable, reason)元组- quota :匹配
insufficient_quota、billing、余额不足等模式 → 不可重试 - auth :匹配
authentication、invalid api key、无权等 → 不可重试 - transient :特定异常类(
APITimeoutError、APIConnectionError等)或 HTTP 状态码{408,409,425,429,500,502,503,504}→ 可重试 - busy :匹配
server busy、rate limit、负载较高等 → 可重试
- quota :匹配
- 退避策略 :
_build_retry_delay_ms()先检查Retry-After/Retry-After-Ms响应头,否则指数退避(base_delay * 2^(attempt-1),上限cap_delay) - SSE 事件 :
_emit_retry_event()通过get_stream_writer()发送llm_retry事件给前端,包含重试次数、等待时间、原因 - 控制流保护 :
GraphBubbleUp异常(interrupt/pause/resume)直接 re-raise,不计入熔断器
#6 GuardrailMiddleware(条件启用)
| 项目 | 说明 |
|---|---|
| 文件 | guardrails/middleware.py |
| 启用条件 | app_config.guardrails.enabled == True 且 guardrails.provider 已配置 |
| 功能 | 在工具执行前对工具调用进行安全策略评估 |
| 生命周期钩子 | wrap_tool_call |
核心流程:
ToolCallRequest → 构建 GuardrailRequest → GuardrailProvider.evaluate() → allow: 执行 / deny: 返回错误 ToolMessage
实现细节:
- 请求构建 :
_build_request()从ToolCallRequest提取tool_name、tool_input,加上agent_id(passport)和 ISO 时间戳,构建GuardrailRequest - 策略评估 :调用
GuardrailProvider.evaluate(gr)(同步)或aevaluate(gr)(异步),返回GuardrailDecision - 拒绝处理 :
_build_denied_message()构建带status="error"的 ToolMessage,包含策略 code 和拒绝原因,引导 Agent 选择替代方案 - 故障模式 :Provider 异常时根据
fail_closed(默认 True)决定:True:返回oap.evaluator_error拒绝决策(保守策略)False:直接调用handler(request)放行(宽松策略)
- 控制流保护 :
GraphBubbleUp直接 re-raise,保留 LangGraph 的 interrupt/pause 信号
#7 SandboxAuditMiddleware
| 项目 | 说明 |
|---|---|
| 文件 | middlewares/sandbox_audit_middleware.py |
| 功能 | 对 bash 命令进行正则分级安全审计 |
| 生命周期钩子 | wrap_tool_call |
核心流程:
仅处理 bash 工具 → 输入校验 → 命令分类(两级扫描) → block: 返回错误 / warn: 执行+追加警告 / pass: 正常执行
实现细节:
- 输入校验 (
_validate_input):拒绝空命令、超长命令(> 10000 字符)、含 null 字节的命令 - 两级命令分类 (
_classify_command):- 第一遍:全命令扫描高危正则(捕获跨语句的结构攻击如 fork bomb)
- 第二遍 :
_split_compound_command()按&&、||、;拆分复合命令(引号感知),逐条分类取最严结果
- 高危规则 (14 条正则,
block):rm -rf /、dd if=、mkfs、管道到 sh、命令注入、/dev/tcp/、fork bomb 等 - 中危规则 (5 条正则,
warn):chmod 777、pip install、sudo/su、PATH 修改 - 引号感知拆分 (
_split_compound_command):逐字符扫描,跟踪单/双引号和转义状态;未闭合引号时 fail-closed 返回整条命令 - 审计日志 :每次 bash 调用记录结构化 JSON(
timestamp、thread_id、command、verdict),命令超 200 字符时截断 - 警告追加 :中危命令执行后在 ToolMessage content 末尾追加
⚠️ Warning提示
#8 ToolErrorHandlingMiddleware
| 项目 | 说明 |
|---|---|
| 文件 | middlewares/tool_error_handling_middleware.py |
| 功能 | 将工具执行异常转化为结构化错误 ToolMessage,避免 Agent 运行中断 |
| 生命周期钩子 | wrap_tool_call |
核心流程:
try: handler(request) → 正常返回
except GraphBubbleUp: re-raise
except Exception: 构建错误 ToolMessage 返回
实现细节:
- 异常捕获 :包裹
handler(request)的同步/异步调用,捕获所有非控制流异常 - 错误消息构建 :
_build_error_message()生成格式化内容:包含工具名、异常类名、异常详情(截断至 500 字符);tool_call_id缺失时使用"missing_tool_call_id"兜底;status="error"标记 - 控制流保护 :
GraphBubbleUp(LangGraph 的 interrupt/pause/resume 信号)直接 re-raise,不被错误处理吞掉 - 日志记录 :
logger.exception()记录完整异常栈,包含工具名和 tool_call_id
阶段二:Lead Agent 业务层
#9 DynamicContextMiddleware
| 项目 | 说明 |
|---|---|
| 文件 | middlewares/dynamic_context_middleware.py |
| 功能 | 将动态上下文(当前日期 + 用户记忆)以 <system-reminder> 形式注入消息历史 |
| 生命周期钩子 | before_agent |
核心流程:
扫描消息历史 → 无历史日期: 注入完整提醒 / 同日期: 跳过 / 跨午夜: 注入日期更新提醒
实现细节:
- 完整提醒 (首次对话):
- 构建
<system-reminder>包含<memory>...</memory>(可选)和<current_date>YYYY-MM-DD, Weekday</current_date> - 记忆注入由
_get_memory_context()获取,受memory.injection_enabled配置控制 - 使用 ID 交换技术 :reminder 消息占用原消息 ID(原地替换),用户消息获得
{id}__user新 ID(紧随其后插入) - 提醒消息标记
hide_from_ui: True和dynamic_context_reminder: True
- 构建
- 跨午夜检测 :
_last_injected_date()逆序扫描消息,通过dynamic_context_reminder标记(非内容匹配)找到上次注入的日期;日期不同时注入轻量级日期更新提醒 - 注入目标过滤 :
_is_user_injection_target()排除已有提醒消息和name="summary"的摘要消息 - 前缀缓存优化:系统 prompt 保持静态不变,动态内容通过 HumanMessage 注入,最大化 LLM prefix-cache 命中率
#10 SummarizationMiddleware(条件启用)
| 项目 | 说明 |
|---|---|
| 文件 | middlewares/summarization_middleware.py |
| 启用条件 | 通过 _create_summarization_middleware() 检查配置是否启用 |
| 功能 | 当消息历史过长时,将旧消息压缩为摘要 |
| 生命周期钩子 | before_model |
核心流程:
计算 token 总数 → 判断是否需要压缩 → 确定截断点 → Skill 救援 → 保留动态上下文 → 触发 hooks → 创建摘要 → 替换消息列表
实现细节:
- 继承自
SummarizationMiddleware:复用基类的 token 计数、阈值判断、消息分区逻辑 - Skill 救援机制 (
_partition_with_skill_rescue):_find_skill_bundles():扫描待压缩消息,识别read_file等读取/mnt/skills/下文件的 AIMessage + ToolMessage 组合_select_bundles_to_rescue():从最新到最旧,在数量预算(默认 5)和 token 预算(默认 25000)内保留最近的 Skill 内容- 救援的 Skill 消息从待压缩列表移到保留列表,避免重要技能上下文被摘要丢失
- 动态上下文保护 :
_preserve_dynamic_context_reminders()将隐藏的<system-reminder>消息从待压缩列表移到保留列表,防止DynamicContextMiddleware在后续轮次误判 - Pre-compression Hook :
BeforeSummarizationHookProtocol,在压缩前将SummarizationEvent(待压缩消息、保留消息、thread_id、agent_name)通知外部组件 - 消息替换策略 :使用
RemoveMessage(id=REMOVE_ALL_MESSAGES)清除全部旧消息,再插入[摘要 HumanMessage(name="summary"), ...保留消息] - 摘要消息格式 :
name="summary"的 HumanMessage,前端可识别并隐藏显示
#11 TodoMiddleware(条件启用)
| 项目 | 说明 |
|---|---|
| 文件 | middlewares/todo_middleware.py |
| 启用条件 | is_plan_mode == True |
| 功能 | 扩展 TodoListMiddleware,增加上下文丢失检测和过早退出防护 |
| 生命周期钩子 | before_model、after_model、wrap_model_call |
核心流程:
before_model: 检测 write_todos 是否还在上下文 → 不在则注入 todo_reminder
after_model: 模型产生最终响应但 todo 未完成 → queue reminder → jump_to("model")
wrap_model_call: 排空 pending reminders → 追加到 request.messages
实现细节:
- 上下文丢失检测 (
before_model):- 检查
state.todos非空但_todos_in_messages(messages)为 False(write_todos调用已被压缩出窗口) - 注入
HumanMessage(name="todo_reminder"),包含格式化的 todo 列表和继续追踪的指令 - 防止重复注入:
_reminder_in_messages()检测是否已有提醒
- 检查
- 过早退出防护 (
after_model):- 检测最后一条 AIMessage 是"干净的最终回答"(无 tool_calls、无 invalid_tool_calls、无 tool-call finish_reason)
- 检查 todos 是否全部完成;未完成则调用
_queue_completion_reminder()排队提醒 - 返回
{"jump_to": "model"}强制回到模型节点继续执行 - 最多 2 次(
_MAX_COMPLETION_REMINDERS)防止无限循环
- 提醒注入 (
wrap_model_call):_drain_completion_reminders()排空当前 thread/run 的待注入提醒- 以
HumanMessage(name="todo_completion_reminder", hide_from_ui=True)追加到request.messages - 非持久化方式,不进入图状态,不泄露到用户可见的消息流
- 清理机制 :
before_agent清除其他 run 的残留提醒;after_agent清除当前 run 的残留提醒;LRU 上限 4096 个 key
#12 TokenUsageMiddleware(条件启用)
| 项目 | 说明 |
|---|---|
| 文件 | middlewares/token_usage_middleware.py |
| 启用条件 | app_config.token_usage.enabled == True |
| 功能 | 记录 token 用量并标注步骤归因 |
| 生命周期钩子 | after_model |
核心流程:
最后一条 AIMessage → usage_metadata → 日志记录 → 构建 attribution → 写入 additional_kwargs
实现细节:
- Token 日志 :从
AIMessage.usage_metadata提取input_tokens、output_tokens、total_tokens(含input_token_details/output_token_details),以logger.info记录 - 步骤归因 (
_build_attribution):write_todos→_build_todo_actions():对比前后 todo 列表,精确标注todo_start/todo_complete/todo_update/todo_removetask→subagent类型,含 description 和 subagent_typeweb_search/image_search→search类型,含 querypresent_files→present_files类型ask_clarification→clarification类型- 其他 →
tool类型,含 tool_name 和 description
- 步骤类型推断 (
_infer_step_kind):根据 actions 推断todo_update/subagent_dispatch/tool_batch/final_answer/thinking - 子 Agent Token 回写 :当
taskToolMessage 完成时,从pop_cached_subagent_usage(tool_call_id)取子 Agent 的 token 用量,回写到派遣它的 AIMessage 的usage_metadata - 去重 :
additional_kwargs.token_usage_attribution相同则跳过,避免无变化的重复更新
#13 TitleMiddleware
| 项目 | 说明 |
|---|---|
| 文件 | middlewares/title_middleware.py |
| 功能 | 在首次用户消息后自动生成线程标题 |
| 生命周期钩子 | after_model |
核心流程:
检查是否首次对话 → 构建 title prompt → 调用 LLM 生成标题 → 解析/截断 → state.title
实现细节:
- 触发条件 (
_should_generate_title):state.title为空 + 消息数 >= 2 + 恰好 1 条用户消息 + >= 1 条助手消息 - Prompt 构建 :从
TitleConfig.prompt_template构建,注入max_words、user_msg[:500]、assistant_msg[:500];助手消息经过_strip_think_tags()移除<think>...</think>推理块 - LLM 调用 (异步):
create_chat_model(name=config.model_name)创建独立模型实例,thinking_enabled=False,attach_tracing=False避免重复 span - 本地降级 (同步路径 / 异步失败时):
_fallback_title()截取用户消息前min(max_chars, 50)字符作为标题,空则"New Conversation" - 标题解析 :
_parse_title()去除<think>标签、首尾引号,截断至max_chars - RunnableConfig 继承 :
_get_runnable_config()继承父级配置,添加run_name="title_agent"和tags=["middleware:title"]
#14 MemoryMiddleware
| 项目 | 说明 |
|---|---|
| 文件 | middlewares/memory_middleware.py |
| 功能 | 在 Agent 执行完成后将对话排入记忆更新队列 |
| 生命周期钩子 | after_agent |
核心流程:
过滤消息 → 检测纠正/强化信号 → 捕获 user_id → 加入 MemoryQueue
实现细节:
- 消息过滤 :
filter_messages_for_memory(messages)仅保留用户输入和助手最终响应,排除工具调用和中间过程 - 前置检查:至少需要 1 条用户消息 + 1 条助手消息才触发记忆更新
- 纠正检测 :
detect_correction(filtered_messages)检测用户是否纠正了助手的行为 - 强化检测 :
detect_reinforcement(filtered_messages)检测用户是否强化了某个观点(仅在未检测到纠正时) - User ID 捕获 :在
after_agent中(请求上下文仍然有效)通过get_effective_user_id()获取 user_id;threading.Timer在不同线程触发,ContextVar 不会传播,因此必须在此刻显式捕获 - 入队处理 :
get_memory_queue().add()将对话和元数据加入MemoryQueue,队列内部使用防抖机制批处理多个更新,异步通过 LLM 摘要更新持久化记忆
#15 ViewImageMiddleware(条件启用)
| 项目 | 说明 |
|---|---|
| 文件 | middlewares/view_image_middleware.py |
| 启用条件 | model_config.supports_vision == True |
| 功能 | 在 LLM 调用前注入图像详情(含 base64 数据) |
| 生命周期钩子 | before_model |
核心流程:
最后一条 AIMessage → 包含 view_image 工具调用? → 所有工具已完成? → 注入图像详情 HumanMessage
实现细节:
- 检测条件链 (
_should_inject_image_message):消息列表非空 + 最后一条 AIMessage 包含view_image工具调用 + 所有 tool_call_id 都有对应 ToolMessage + 尚未注入过图像详情消息 - 图像消息构建 (
_create_image_details_message):从state.viewed_images读取图像数据,每张图像生成文本描述(路径、MIME 类型)和image_url内容块(data:{mime};base64,{data}格式) - 消息注入 :创建
HumanMessage(content=content_blocks)添加到 state.messages,LLM 自动接收并分析图像 - 去重机制:检查 assistant 消息之后是否已有包含特定标记文本的 HumanMessage,避免重复注入
#16 DeferredToolFilterMiddleware(条件启用)
| 项目 | 说明 |
|---|---|
| 文件 | middlewares/deferred_tool_filter_middleware.py |
| 启用条件 | app_config.tool_search.enabled == True |
| 功能 | 从模型绑定中过滤掉延迟注册的工具 schema,节省上下文 token |
| 生命周期钩子 | wrap_model_call、wrap_tool_call |
核心流程:
wrap_model_call: request.tools → 过滤 deferred 工具 → handler(filtered_request)
wrap_tool_call: 检查工具是否在 deferred registry → 是: 返回错误提示 / 否: 正常执行
实现细节:
- Schema 过滤 (
_filter_tools):从get_deferred_registry()获取延迟工具名称集合,从request.tools中移除匹配的工具 schema;ToolNode 仍持有全部工具用于执行路由 - 直接调用拦截 (
_blocked_tool_message):若模型直接调用延迟工具(未经tool_search提升),返回错误 ToolMessage 提示先调用tool_search - 设计目的 :MCP 工具数量可能很多,全部发送给 LLM 会浪费上下文 token;通过延迟注册 +
tool_search发现模式,仅暴露需要的工具
#17 SubagentLimitMiddleware(条件启用)
| 项目 | 说明 |
|---|---|
| 文件 | middlewares/subagent_limit_middleware.py |
| 启用条件 | subagent_enabled == True |
| 功能 | 限制单次模型响应中并发子 Agent(task 工具调用)的数量 |
| 生命周期钩子 | after_model |
核心流程:
最后一条 AIMessage.tool_calls → 统计 task 调用数量 → 超过上限: 截断多余调用 → 替换 AIMessage
实现细节:
- 限制范围 :
_clamp_subagent_limit()将值限制在 2, 4 区间,默认 3 - 截断逻辑 (
_truncate_task_calls):筛选name == "task"的工具调用索引,超过max_concurrent的部分标记为 drop - 消息替换 :
clone_ai_message_with_tool_calls(last_msg, truncated_tool_calls)克隆 AIMessage 并替换 tool_calls 列表,保留相同id触发add_messagesreducer 的替换语义 - 日志警告:截断时记录被丢弃的调用数量
#18 LoopDetectionMiddleware(条件启用)
| 项目 | 说明 |
|---|---|
| 文件 | middlewares/loop_detection_middleware.py |
| 启用条件 | app_config.loop_detection.enabled == True |
| 功能 | 检测并打断重复的工具调用循环 |
| 生命周期钩子 | after_model、wrap_model_call、after_agent |
核心流程:
after_model: 哈希 tool_calls → 滑动窗口统计 → 达到 warn: 入队警告 / 达到 hard_limit: 清除 tool_calls
wrap_model_call: 排空 pending warnings → 追加到 request.messages
实现细节:
- 工具调用哈希 (
_hash_tool_calls):- 每个调用生成
name:stable_key,key 由_stable_tool_key()根据工具类型选择归一化策略 read_file:路径 + 行号桶(每 200 行一桶),避免微小偏移产生不同哈希write_file/str_replace:全 args 哈希(内容敏感操作需区分不同 payload)- 其他:仅取 salient fields(
path/url/query/command等) - 多调用排序后 MD5 取前 12 位,保证顺序无关性
- 每个调用生成
- 滑动窗口 :
OrderedDict存储每线程最近 N 次(默认 20)调用哈希;LRU 淘汰策略限制 100 个线程 - 警告注入 (延迟到
wrap_model_call):after_model检测到重复后入队到_pending_warnings[(thread_id, run_id)]wrap_model_call排空队列,以HumanMessage追加到消息列表- 延迟原因:
after_model时 ToolMessage 尚未生成,直接插入消息会破坏 AIMessage→ToolMessage 配对
- 硬限制 :达到
hard_limit时直接清除 AIMessage 的tool_calls(通过clone_ai_message_with_tool_calls),强制模型生成文本回复 - 工具频率检测 :独立于哈希检测,统计同一工具类型的调用频次(默认 warn=30, hard=50);支持 per-tool 覆盖(如
bash在批处理场景可提高阈值) - 线程安全 :所有共享状态通过
threading.Lock保护
#19 Custom Middlewares
| 项目 | 说明 |
|---|---|
| 位置 | 通过 custom_middlewares 参数传入 |
| 功能 | 用户自定义中间件,插入在 ClarificationMiddleware 之前 |
| 说明 | 可使用 @Next/@Prev 装饰器指定相对于内置中间件的精确位置;create_deerflow_agent() 中的 extra_middleware 参数通过相同的定位机制工作 |
#20 SafetyFinishReasonMiddleware(条件启用)
| 项目 | 说明 |
|---|---|
| 文件 | middlewares/safety_finish_reason_middleware.py |
| 启用条件 | app_config.safety_finish_reason.enabled == True(默认启用) |
| 功能 | 当模型提供商因安全原因终止响应时,抑制工具执行 |
| 生命周期钩子 | after_model |
核心流程:
最后一条 AIMessage → 有 tool_calls? → 遍历 detectors 检测安全终止 → 命中: 清除 tool_calls + 追加说明 + 可观测性事件
实现细节:
- 检测器链 (
_detect):遍历self._detectors(默认default_detectors()),每个检测器实现SafetyTerminationDetectorProtocol(name+detect(message));检测 OpenAIcontent_filter、Anthropicrefusal、GeminiSAFETY等;单个检测器异常不中断链 - 配置加载 (
from_config):支持通过use字段反射加载自定义检测器类(resolve_variable(entry.use));空列表被拒绝(要求使用enabled=false) - 消息重写 (
_build_suppressed_message):clone_ai_message_with_tool_calls(message, [])清除结构化和原始 tool_calls;追加用户说明文本到 content(支持 list/str 两种格式);additional_kwargs.safety_termination存储可观测性数据 - SSE 事件 (
_emit_event):通过get_stream_writer()发送safety_termination事件,通知前端工具调用被抑制 - 审计持久化 (
_record_audit_event):通过RunJournal.record_middleware()写入 RunEventStore,支持事后查询"哪些 run 被安全抑制" - 放置顺序关键 :必须在 LoopDetection 之后注册------LangChain 逆序分发
after_model,Safety 先运行清除 tool_calls,Loop 再看到的是已清理的消息,避免误报
#21 ClarificationMiddleware(始终最后)
| 项目 | 说明 |
|---|---|
| 文件 | middlewares/clarification_middleware.py |
| 功能 | 拦截 ask_clarification 工具调用,中断执行并向用户展示澄清问题 |
| 生命周期钩子 | wrap_tool_call |
核心流程:
tool_call.name == "ask_clarification"? → 是: 格式化问题 → 返回 Command(goto=END) / 否: 正常执行
实现细节:
- 工具拦截 :检查
request.tool_call.name == "ask_clarification",非此工具直接透传 - 参数提取 :从
args读取question、clarification_type、context、options - 选项归一化 :处理模型将数组参数序列化为 JSON 字符串的情况(如 Qwen3-Max),
json.loads()反序列化 - 消息格式化 (
_format_clarification_message):按clarification_type选择图标(missing_info→❓、ambiguous_requirement→🤔、approach_choice→🔀、risk_confirmation→⚠️、suggestion→💡);有 context 时先展示背景再展示问题;有 options 时编号列出 - 执行中断 :返回
Command(update={"messages": [ToolMessage]}, goto=END);ToolMessage 使用确定性 ID(clarification:{tool_call_id}或 SHA256 前 16 位),确保重试时替换而非追加 - 必须最后注册 :LangChain 逆序分发
after_model,Clarification 需要最先拦截ask_clarification工具调用(在 Safety 清除 tool_calls 之前拦截到)
三、中间件执行流程图
#mermaid-svg-lhPu19gizcjFy46j{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-lhPu19gizcjFy46j .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-lhPu19gizcjFy46j .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-lhPu19gizcjFy46j .error-icon{fill:#552222;}#mermaid-svg-lhPu19gizcjFy46j .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-lhPu19gizcjFy46j .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-lhPu19gizcjFy46j .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-lhPu19gizcjFy46j .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-lhPu19gizcjFy46j .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-lhPu19gizcjFy46j .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-lhPu19gizcjFy46j .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-lhPu19gizcjFy46j .marker{fill:#333333;stroke:#333333;}#mermaid-svg-lhPu19gizcjFy46j .marker.cross{stroke:#333333;}#mermaid-svg-lhPu19gizcjFy46j svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-lhPu19gizcjFy46j p{margin:0;}#mermaid-svg-lhPu19gizcjFy46j .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-lhPu19gizcjFy46j .cluster-label text{fill:#333;}#mermaid-svg-lhPu19gizcjFy46j .cluster-label span{color:#333;}#mermaid-svg-lhPu19gizcjFy46j .cluster-label span p{background-color:transparent;}#mermaid-svg-lhPu19gizcjFy46j .label text,#mermaid-svg-lhPu19gizcjFy46j span{fill:#333;color:#333;}#mermaid-svg-lhPu19gizcjFy46j .node rect,#mermaid-svg-lhPu19gizcjFy46j .node circle,#mermaid-svg-lhPu19gizcjFy46j .node ellipse,#mermaid-svg-lhPu19gizcjFy46j .node polygon,#mermaid-svg-lhPu19gizcjFy46j .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-lhPu19gizcjFy46j .rough-node .label text,#mermaid-svg-lhPu19gizcjFy46j .node .label text,#mermaid-svg-lhPu19gizcjFy46j .image-shape .label,#mermaid-svg-lhPu19gizcjFy46j .icon-shape .label{text-anchor:middle;}#mermaid-svg-lhPu19gizcjFy46j .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-lhPu19gizcjFy46j .rough-node .label,#mermaid-svg-lhPu19gizcjFy46j .node .label,#mermaid-svg-lhPu19gizcjFy46j .image-shape .label,#mermaid-svg-lhPu19gizcjFy46j .icon-shape .label{text-align:center;}#mermaid-svg-lhPu19gizcjFy46j .node.clickable{cursor:pointer;}#mermaid-svg-lhPu19gizcjFy46j .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-lhPu19gizcjFy46j .arrowheadPath{fill:#333333;}#mermaid-svg-lhPu19gizcjFy46j .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-lhPu19gizcjFy46j .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-lhPu19gizcjFy46j .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-lhPu19gizcjFy46j .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-lhPu19gizcjFy46j .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-lhPu19gizcjFy46j .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-lhPu19gizcjFy46j .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-lhPu19gizcjFy46j .cluster text{fill:#333;}#mermaid-svg-lhPu19gizcjFy46j .cluster span{color:#333;}#mermaid-svg-lhPu19gizcjFy46j div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-lhPu19gizcjFy46j .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-lhPu19gizcjFy46j rect.text{fill:none;stroke-width:0;}#mermaid-svg-lhPu19gizcjFy46j .icon-shape,#mermaid-svg-lhPu19gizcjFy46j .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-lhPu19gizcjFy46j .icon-shape p,#mermaid-svg-lhPu19gizcjFy46j .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-lhPu19gizcjFy46j .icon-shape .label rect,#mermaid-svg-lhPu19gizcjFy46j .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-lhPu19gizcjFy46j .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-lhPu19gizcjFy46j .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-lhPu19gizcjFy46j :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Agent 执行出口
阶段二: 业务层 - before_model / after_model
阶段一: 基础设施层 - wrap_model_call / wrap_tool_call
阶段一: 基础设施层 - before_agent
Agent 生命周期入口
Agent 开始执行
#1 ThreadDataMiddleware
创建线程数据目录
#2 UploadsMiddleware
(before_agent 注入上传文件信息)
#3 SandboxMiddleware
获取/分配沙箱环境
#4 DanglingToolCallMiddleware
修补悬空工具调用
#5 LLMErrorHandlingMiddleware
LLM 重试 + 熔断器
#6 GuardrailMiddleware
工具调用安全策略评估
(条件启用)
#7 SandboxAuditMiddleware
Bash 命令分级审计
#8 ToolErrorHandlingMiddleware
工具异常 → 错误 ToolMessage
#9 DynamicContextMiddleware
注入日期 + 记忆
#10 SummarizationMiddleware
长对话压缩为摘要
(条件启用)
#11 TodoMiddleware
任务列表 + 过早退出防护
(plan_mode 启用)
#12 TokenUsageMiddleware
Token 用量追踪 + 归因
(条件启用)
#13 TitleMiddleware
自动生成线程标题
#14 MemoryMiddleware
对话 → 记忆更新队列
#15 ViewImageMiddleware
注入图像 base64 数据
(vision 模型启用)
#16 DeferredToolFilterMiddleware
过滤延迟工具 schema
(tool_search 启用)
#17 SubagentLimitMiddleware
截断超额并发子 Agent
(subagent 启用)
#18 LoopDetectionMiddleware
检测/打断重复工具调用循环
(条件启用)
#19 Custom Middlewares
用户自定义中间件
#20 SafetyFinishReasonMiddleware
安全终止 → 抑制工具执行
(条件启用)
#21 ClarificationMiddleware
拦截澄清问题 → 中断执行
(始终最后)
Agent 执行结束
四、after_model 逆序执行顺序
LangChain 的 after_model 钩子按 逆序 分发(最后注册的先执行),因此实际 after_model 执行顺序为:
ClarificationMiddleware ← 最先执行 (但此中间件主要用 wrap_tool_call)
SafetyFinishReasonMiddleware ← 第二执行:先清除安全终止的 tool_calls
Custom Middlewares
LoopDetectionMiddleware ← 在 Safety 之后:看到的是已清理的消息
SubagentLimitMiddleware
DeferredToolFilterMiddleware
ViewImageMiddleware
MemoryMiddleware
TitleMiddleware
TokenUsageMiddleware
TodoMiddleware
SummarizationMiddleware
DynamicContextMiddleware
...
ToolErrorHandlingMiddleware
SandboxAuditMiddleware
GuardrailMiddleware
LLMErrorHandlingMiddleware
DanglingToolCallMiddleware ← 最后执行
这样设计确保 SafetyFinishReasonMiddleware 先于 LoopDetectionMiddleware 运行------
避免因安全终止产生的截断 tool_calls 触发循环检测的误报。
五、生命周期钩子对照表
| # | 中间件 | before_agent | before_model | wrap_model_call | after_model | wrap_tool_call | after_agent |
|---|---|---|---|---|---|---|---|
| 1 | ThreadDataMiddleware | ✅ | |||||
| 2 | UploadsMiddleware | ✅ | |||||
| 3 | SandboxMiddleware | ✅ | ✅ | ✅ | |||
| 4 | DanglingToolCallMiddleware | ✅ | |||||
| 5 | LLMErrorHandlingMiddleware | ✅ | |||||
| 6 | GuardrailMiddleware | ✅ | |||||
| 7 | SandboxAuditMiddleware | ✅ | |||||
| 8 | ToolErrorHandlingMiddleware | ✅ | |||||
| 9 | DynamicContextMiddleware | ✅ | ✅ | ||||
| 10 | SummarizationMiddleware | ✅ | |||||
| 11 | TodoMiddleware | ✅ | ✅ | ||||
| 12 | TokenUsageMiddleware | ✅ | ✅ | ||||
| 13 | TitleMiddleware | ✅ | |||||
| 14 | MemoryMiddleware | ✅ | |||||
| 15 | ViewImageMiddleware | ✅ | |||||
| 16 | DeferredToolFilterMiddleware | ✅ | ✅ | ||||
| 17 | SubagentLimitMiddleware | ✅ | |||||
| 18 | LoopDetectionMiddleware | ✅ | ✅ | ✅ | |||
| 19 | Custom Middlewares | --- | --- | --- | --- | --- | --- |
| 20 | SafetyFinishReasonMiddleware | ✅ | |||||
| 21 | ClarificationMiddleware | ✅ |