从脱敏到审批:我用 LangChain 11 个中间件给羽毛球 AI 助手装了一整套"安全阀"

一个生产级 Agent 不能只会"回答问题",还要会省钱、防泄漏、能容灾、懂审批。

本文基于 LangChain 1.x Middleware 系统,结合真实羽毛球 AI 助手项目,逐一拆解 11 个中间件的配置、原理、源码级分析和实战案例。


一、前言

去年我开始做一个羽毛球 AI 助手项目(Badminton Agent),核心需求是:用户上传视频 → AI 分析动作 → 生成教学建议 → 制定训练计划。听起来很简单对吧?

但在实际开发中,我发现一个 Agent 如果只配一个模型和几个工具,根本撑不住生产环境。你会遇到:

  • 隐私泄露:用户不小心把邮箱、手机号发到对话里,直接传给大模型
  • Token 爆炸:聊了几轮后上下文爆掉,模型开始胡言乱语
  • 费用失控:Agent 疯狂调用工具,API 账单一天比一天高
  • 单点故障:主模型 DeepSeek 突然限流,服务直接瘫痪
  • 合规风险:训练计划直接生成,用户照着练受伤了谁负责?

为了解决这些问题,我基于 LangChain 1.x 的 Middleware 系统,给 Agent 装上了一条 11 个中间件的责任链。每个中间件只干一件事,但组合起来,就形成了一套完整的"安全阀"体系。

本文会逐一拆解这 11 个中间件的 配置、作用、原理、内部实现、边界案例 ,并给出 真实业务场景的代码示例。如果你正在构建生产级 Agent,这篇文章应该能帮你少踩不少坑。


二、整体架构概览

2.1 中间件执行顺序

复制代码
用户输入
   │
   ▼
┌──────────────────────────────────────────────────────────────────┐
│  1. PIIMiddleware              ← 用户输入脱敏                    │
│  2. ContextEditingMiddleware   ← 10万 tokens 触发,清理旧工具结果 │
│  3. SummarizationMiddleware    ← 长对话自动摘要                  │
│  4. TodoListMiddleware         ← 复杂任务列清单                  │
│  5. LLMToolSelectorMiddleware  ← 用便宜模型筛选工具              │
│  6. ModelCallLimitMiddleware   ← 防模型调用超支                  │
│  7. ModelRetryMiddleware       ← 模型调用失败重试                │
│  8. ModelFallbackMiddleware    ← 模型容灾切换                    │
│  9. ToolCallLimitMiddleware    ← 防工具滥用                      │
│ 10. ToolRetryMiddleware        ← 工具调用失败重试                │
│ 11. HumanInTheLoopMiddleware   ← 高风险操作人工审批               │
└──────────────────────────────────────────────────────────────────┘
   │
   ▼
  主模型执行 ──→ 工具调用 ──→ 返回结果

2.2 四层架构

层次 包含中间件 核心目标 类比现实
输入安全层 PII, ContextEditing, Summarization 进模型之前,先脱敏、剪裁、压缩 安检 + 行李打包
成本控制层 TodoList, LLMToolSelector, ModelCallLimit, ToolCallLimit 少花钱,多办事 项目经理 + 预算控制
稳定性层 ModelRetry, ModelFallback, ToolRetry 挂了自动救,用户无感知 灾备系统 + 备用发电机
合规风控层 HumanInTheLoop 高风险操作,必须人工确认 领导签字审批

2.3 LangChain Middleware 原理简述

在深入每个中间件之前,有必要先理解 LangChain 中间件的运行机制。

LangChain 1.x 的 Middleware 是一个 洋葱圈模型(Onion Model)

markdown 复制代码
                输入
                  │
    ┌─────────────┼─────────────┐
    │  中间件 1                   │
    │  ┌───────────┼───────────┐ │
    │  │  中间件 2               │ │
    │  │  ┌─────────┼─────────┐ │ │
    │  │  │  中间件 3           │ │ │
    │  │  │  ┌───────┼───────┐ │ │ │
    │  │  │  │  主模型执行     │ │ │ │
    │  │  │  └───────┼───────┘ │ │ │
    │  │  └─────────┼─────────┘ │ │
    │  └───────────┼───────────┘ │
    └─────────────┼─────────────┘
                  │
                输出

每个中间件可以:

  • 在输入阶段:修改用户消息(如 PII 脱敏)
  • 在输出阶段:修改模型返回结果(如 Summarization 注入摘要)
  • 拦截执行:阻止消息进入模型(如 PII block 策略)
  • 暂停执行:等待外部输入后再继续(如 HITL 人工审批)

理解这个模型后,再看每个中间件的具体实现,思路会清晰很多。


三、输入安全层

3.1 PIIMiddleware --- 用户输入脱敏

核心作用

在用户消息发给模型之前,自动检测并处理敏感信息(PII, Personally Identifiable Information)。

为什么需要

如果用户问"我的邮箱是 abc@example.com,帮我查一下训练计划",而你的模型是第三方 API(如 DeepSeek、OpenAI),那用户的邮箱就被传到了第三方服务器。这在 GDPR(欧盟通用数据保护条例)、个保法(中国个人信息保护法)下都是严重的合规风险。

支持的 PII 类型

LangChain 内置了以下 PII 检测器:

PII 类型 检测范围 默认策略 配置参数
email 邮箱地址 redact piiMiddleware("email", {...})
url 网址链接 redact piiMiddleware("url", {...})
credit_card 信用卡号 block piiMiddleware("credit_card", {...})
phone 电话号码 redact piiMiddleware("phone", {...})
ip_address IP 地址 redact piiMiddleware("ip_address", {...})
mac_address MAC 地址 redact piiMiddleware("mac_address", {...})
ssn 社会安全号 block piiMiddleware("ssn", {...})

三种策略详解

typescript 复制代码
// 策略 1: redact(替换)
// 将敏感信息替换为占位符,保留对话流程
piiMiddleware("email", {
  strategy: "redact",
  placeholder: "[REDACTED_EMAIL]",  // 自定义占位符
  applyToInput: true,
  applyToOutput: false,
  applyToToolResults: false,
})

// 策略 2: block(拦截)
// 直接阻止消息进入模型,返回错误提示
piiMiddleware("credit_card", {
  strategy: "block",
  applyToInput: true,
  applyToOutput: false,
  applyToToolResults: false,
  message: "检测到信用卡信息,已拦截。请勿在对话中发送敏感金融信息。", // 自定义提示
})

// 策略 3: mask(部分遮盖)
// 保留部分信息用于上下文,如 abc***@example.com
piiMiddleware("email", {
  strategy: "mask",
  maskOptions: {
    maskChar: "*",
    visibleChars: 3,     // 开头保留 3 个字符
    visibleEndChars: 0,  // 结尾不保留
  },
  applyToInput: true,
})

三种策略的效果对比

策略 输入 模型实际看到 适用场景
redact john@example.com [REDACTED_EMAIL] 大多数场景
block 4111-1111-1111-1111 ❌ 消息被拦截 金融、身份证等强敏感信息
mask john@example.com joh***@example.com 需要保留域名信息

正则检测原理(源码级分析)

PIIMiddleware 底层使用正则表达式进行检测。以邮箱为例:

typescript 复制代码
// LangChain 内部邮箱检测的正则示例
const EMAIL_REGEX = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;

// URL 检测
const URL_REGEX = /https?:\/\/[^\s/$.?#].[^\s]*/gi;

// 信用卡检测(Luhn 算法 + 正则)
const CREDIT_CARD_REGEX = /\b(?:\d[ -]*?){13,16}\b/g;
// 检测到后还会用 Luhn 算法校验,避免误杀

真实业务场景

场景 1:用户发送邮箱

css 复制代码
用户:帮我查一下这个视频的动作问题,我的邮箱是 john@example.com,方便发报告。
处理后:帮我查一下这个视频的动作问题,我的邮箱是 [REDACTED_EMAIL],方便发报告。

场景 2:用户发送 YouTube 链接

ini 复制代码
用户:这个视频你看看 https://www.youtube.com/watch?v=xxxxx
处理后:这个视频你看看 [REDACTED_URL]

注意:URL 脱敏后,视频分析工具也无法收到正确的 URL 了。所以需要根据业务场景决定是否对 URL 做脱敏。

场景 3:误杀问题

css 复制代码
用户:我的邮箱是 123456@qq.com,帮我查一下
处理后:我的邮箱是 [REDACTED_EMAIL],帮我查一下

这是正确的行为。但如果用户说的是"请联系我之前的教练",教练的名字恰好类似邮箱格式,就需要考虑是否要调整检测规则。

最佳实践

  1. 不要对所有 PII 类型一刀切:根据业务场景选择性启用
  2. applyToInput: true 就够了:通常不需要对 AI 输出和工具结果做脱敏
  3. URL 脱敏要谨慎:如果 Agent 需要处理链接,URL 脱敏可能影响功能
  4. 测试你的正则:用各种边界情况测试 PII 检测,避免误杀或漏杀

3.2 ContextEditingMiddleware --- 清理旧工具结果

核心作用

当对话上下文超过阈值时,自动清理旧的工具调用结果(ToolMessage),只保留最近 N 条,释放上下文窗口。

为什么需要

Agent 每调用一次工具,工具返回的结果(ToolMessage)就会追加到对话历史中。如果用户连续分析 10 个视频,每个视频的分析结果可能就有 1-2 万 tokens,10 轮下来直接爆掉上下文窗口。

更关键的是------旧工具结果对当前对话已经没有价值了。比如用户第 5 轮问的是网前球技术,第 1 轮的杀球分析结果完全没有保留的必要,它只是占着上下文位置。

默认配置

typescript 复制代码
// 使用默认配置
contextEditingMiddleware()

// 等价于:
contextEditingMiddleware({
  edits: [
    new ClearToolUsesEdit({
      trigger: { tokens: 100000 },  // 10 万 tokens 触发
      keep: { messages: 3 },        // 保留最近 3 条
    }),
  ],
  tokenCountMethod: "approx",       // 近似计数(更快)
})

完整配置

typescript 复制代码
contextEditingMiddleware({
  edits: [
    new ClearToolUsesEdit({
      // 触发条件(支持多种方式)
      trigger: { tokens: 100000 },

      // 保留策略
      keep: { messages: 3 },        // 保留最近 3 条工具结果

      // 排除特定工具(永远不清理)
      excludeTools: ["searchKnowledgeTool"],

      // 是否同时清理工具调用的输入参数
      clearToolInputs: false,

      // 清理后的占位符
      placeholder: "[cleared]",

      // 模型引用(用于百分比触发方式)
      // model: chatModel,
    }),
  ],
  tokenCountMethod: "approx",  // "approx" | "model"
})

触发条件详解

typescript 复制代码
// 方式一:纯 Token 阈值
// 当总 tokens ≥ 100,000 时触发
trigger: { tokens: 100000 }

// 方式二:Token + 消息数双重条件
// 当总 tokens ≥ 100,000 且 消息数 ≥ 50 时触发
// 两个条件必须同时满足
trigger: { tokens: 100000, messages: 50 }

// 方式三:多个条件组(任意一组满足即可)
// 条件组 A: tokens ≥ 100,000 且 messages ≥ 50
// 条件组 B: tokens ≥ 50,000 且 messages ≥ 100
// 任一条件组满足即触发
trigger: [
  { tokens: 100000, messages: 50 },
  { tokens: 50000, messages: 100 },
]

// 方式四:模型上下文窗口百分比(推荐)
// 当达到模型最大输入 token 的 80% 时触发
// 自动适配不同模型,不需要手动调参
trigger: { fraction: 0.8 }

保留策略详解

typescript 复制代码
// 按消息数保留(推荐)
keep: { messages: 3 }  // 保留最近 3 条工具结果

// 按 Token 数保留
keep: { tokens: 5000 }  // 保留最近 5000 tokens 的工具结果

// 按百分比保留(需要传 model 参数)
keep: { fraction: 0.3 }  // 保留占模型最大输入 30% 的工具结果

清理前后对比

清理前(10 万 tokens)

makefile 复制代码
Human: 分析这个杀球视频
ToolResult: [杀球分析结果... 2万 tokens]
Human: 分析这个高远球视频
ToolResult: [高远球分析结果... 2万 tokens]
Human: 分析这个网前球视频
ToolResult: [网前球分析结果... 2万 tokens]
Human: 分析这个反手视频
ToolResult: [反手分析结果... 2万 tokens]

清理后(约 4 万 tokens)

makefile 复制代码
Human: 分析这个杀球视频
ToolResult: [cleared]  ← 旧结果被清理
Human: 分析这个高远球视频
ToolResult: [cleared]  ← 旧结果被清理
Human: 分析这个网前球视频
ToolResult: [cleared]  ← 旧结果被清理
Human: 分析这个反手视频
ToolResult: [反手分析结果... 2万 tokens]  ← 最近 1 条保留

ClearToolUsesEdit 内部原理

ClearToolUsesEdit 实现了 ContextEdit 接口,其核心逻辑如下:

typescript 复制代码
// 伪代码:ClearToolUsesEdit 内部实现
class ClearToolUsesEdit implements ContextEdit {
  async apply({ messages, countTokens, model }) {
    // 1. 计算当前 token 总数
    const currentTokens = await countTokens(messages);

    // 2. 检查是否达到触发条件
    if (!this.shouldTrigger(currentTokens, messages.length)) {
      return;  // 未触发,直接返回
    }

    // 3. 找到所有工具结果(AIMessage + ToolMessage 配对)
    const toolResults = this.findToolResults(messages);

    // 4. 确定要保留的工具结果数量
    const keepCount = this.resolveKeepCount(model);

    // 5. 标记需要清理的旧结果
    const toClear = toolResults.slice(0, -keepCount);

    // 6. 将旧结果替换为占位符
    for (const idx of toClear) {
      messages[idx].content = this.placeholder;
    }

    // 7. 如果设置了 clearToolInputs,同时清理工具调用参数
    if (this.clearToolInputs) {
      for (const toolCall of this.getToolCalls(messages, toClear)) {
        toolCall.args = "{}";
      }
    }
  }
}

自定义编辑策略

如果 ClearToolUsesEdit 不满足你的需求,可以实现自定义策略:

typescript 复制代码
import { type ContextEdit, type TokenCounter } from "langchain";
import type { BaseMessage } from "@langchain/core/messages";

// 自定义策略:只保留最新的 HumanMessage
class KeepOnlyRecentHumanMessages implements ContextEdit {
  constructor(private keepRecent: number = 10) {}

  async apply({ messages, countTokens }: {
    messages: BaseMessage[];
    countTokens: TokenCounter;
  }): Promise<void> {
    const tokens = await countTokens(messages);

    if (tokens > 50000) {
      const humanMessages: number[] = [];

      // 找到所有 HumanMessage 的索引
      for (let i = 0; i < messages.length; i++) {
        if (messages[i]._getType() === "human") {
          humanMessages.push(i);
        }
      }

      // 删除旧的 HumanMessage,只保留最近的 N 条
      const toRemove = humanMessages.slice(0, -this.keepRecent);
      for (let i = toRemove.length - 1; i >= 0; i--) {
        messages.splice(toRemove[i]!, 1);
      }
    }
  }
}

// 使用自定义策略
contextEditingMiddleware({
  edits: [new KeepOnlyRecentHumanMessages(5)],
})

和 SummarizationMiddleware 的区别

维度 ContextEditing Summarization
处理对象 工具结果(ToolMessage) 所有历史消息
处理方式 替换为占位符 [cleared] 压缩为自然语言摘要
触发阈值 高(10 万 tokens) 低(5000 tokens)
保留策略 保留最近 N 条工具结果 保留最近 N 条消息
信息损失 完全丢失工具结果内容 摘要保留了关键信息
适用场景 清理大块无用数据 压缩仍有价值的对话
执行成本 几乎为零(纯内存操作) 需要调用模型做摘要
推荐搭配 先清理,后摘要 先清理减少数据量,再摘要更省钱

最佳实践

  1. ContextEditing 放在 Summarization 前面:先清理掉大块无用数据,再对剩余数据做摘要,更省钱
  2. excludeTools 保护重要工具:知识库检索结果等需要保留的工具,不要被清理
  3. clearToolInputs: false:保留工具调用参数方便追溯调试
  4. 百分比触发方式最灵活trigger: { fraction: 0.8 } 自动适配不同模型的上下文窗口

3.3 SummarizationMiddleware --- 长对话自动摘要

核心作用

当对话历史超过阈值时,自动用轻量模型将旧消息压缩为摘要,保留最近 N 条消息不动。

为什么需要

用户和 Agent 聊了 20 轮后,早期的对话内容(比如"帮我查一下林丹的职业生涯")虽然对当前上下文仍有参考价值,但逐字保留太浪费 Token。正确的做法是:把旧内容压缩成一段摘要,释放上下文窗口给新消息

配置

typescript 复制代码
summarizationMiddleware({
  model: summarizerModel,       // 做摘要的模型(用便宜的)
  trigger: { tokens: 5000 },   // 超过 5000 tokens 触发
  keep: { messages: 15 },      // 保留最近 15 条不动
})

参数详解

参数 说明 类型 推荐值
model 执行摘要的模型 BaseLanguageModel 便宜的模型(DeepSeek Chat / GPT-4o-mini)
trigger.tokens 触发摘要的 Token 阈值 number 5000-10000
trigger.messages 触发摘要的消息数阈值 number 可选,不常用
keep.messages 保留的最新消息数 number 10-20
keep.tokens 保留的最新 Token 数 number 可选,替代 messages
tokenCounter 自定义 Token 计数函数 TokenCounter 可选,默认用 tiktoken

工作流程详解

ini 复制代码
触发前(6000 tokens,超过 5000 阈值):
  ┌─────────────────────────────────────────────────┐
  │ [消息1] Human: 帮我查一下林丹的职业生涯  [500t]  │
  │ [消息2] AI: 林丹是中国羽毛球运动员,2次奥运冠军...│[800t]│
  │ [消息3] Human: 分析这个杀球视频          [2000t] │
  │ [消息4] Tool: 杀球分析结果是...          [2000t] │
  │ [消息5] Human: 那他的反手怎么样?        [100t]  │
  │ [消息6] AI: 反手需要...                 [600t]  │
  └─────────────────────────────────────────────────┘

触发后(约 4000 tokens):
  ┌─────────────────────────────────────────────────┐
  │ [摘要] 用户先查询了林丹的职业生涯,得到其成就介绍;│
  │        然后用户上传杀球视频,AI 分析了技术要点;  │
  │        接着用户询问反手技术...(约 500 tokens)  │
  │ [消息5] Human: 那他的反手怎么样?       [100t]  │
  │ [消息6] AI: 反手需要...                 [600t]  │
  └─────────────────────────────────────────────────┘

摘要模型的 Prompt 模板

LangChain 内部使用的摘要 prompt 大致如下:

typescript 复制代码
// 内部使用的摘要 prompt(简化版)
const SUMMARIZATION_PROMPT = `
你是一个对话摘要助手。请阅读以下对话历史,生成一个简洁的摘要,
保留对后续对话有用的关键信息。

对话历史:
{messages}

请生成一个简洁的摘要(100-200 字),
包含用户的主要意图、已经完成的操作、以及重要的上下文信息。
`;

Token 计数方式

SummarizationMiddleware 支持两种 Token 计数方式:

typescript 复制代码
// 方式一:使用 tiktoken(精确计数,默认)
// 需要安装 tiktoken 包,支持国内网络
import { tiktokenCounter } from "langchain";
await tiktokenCounter(messages);

// 方式二:使用模型自带的计数(如 OpenAI 的 countTokens)
// 更精确,但需要 API 调用,速度慢
await model.countTokens(messages);

// 方式三:近似计数(快,但不太精确)
// 按字符数 / 4 估算
const approxTokens = messages.reduce((sum, msg) =>
  sum + Math.ceil(msg.content.length / 4), 0);

真实业务场景

场景 1:多轮视频分析对话

arduino 复制代码
用户第 1 轮:分析这个杀球视频
用户第 2 轮:分析这个高远球视频
用户第 3 轮:分析这个网前球视频
用户第 4 轮:对比这三个动作,给我出个训练计划

当第 4 轮发出时,前面 3 轮的分析结果已经占了很多 tokens。
SummarizationMiddleware 自动摘要为:
"用户上传了 3 个视频(杀球、高远球、网前球),AI 已分别给出分析结果。
现在用户要求对比三个动作并生成训练计划。"

场景 2:跨会话上下文

arduino 复制代码
用户第 1 天:帮我查一下林丹的职业生涯
用户第 2 天:继续昨天的,再帮我查一下李宗伟

摘要会保留"用户昨天查询了林丹的职业生涯,AI 已给出完整介绍",
所以第 2 天 Agent 知道"继续昨天的"是什么意思。

边界案例

案例 1:摘要过于简略,丢失关键信息

sql 复制代码
问题:摘要把"用户要求每周训练 5 次"压缩成了"用户要求训练"
      导致后续 Agent 不知道具体频率。
解决方案:降低 trigger 阈值,增加 keep.messages 数量,
          或者用更强大的模型做摘要。

案例 2:摘要模型和主模型不一致

markdown 复制代码
问题:用 GPT-4o-mini 做摘要,但主模型是 DeepSeek,
      摘要风格和主模型的理解方式不匹配。
解决方案:尽量用同一系列或能力相近的模型做摘要,
          或者在摘要 prompt 中指定格式要求。

最佳实践

  1. 搭配 ContextEditing 使用:先清理旧工具结果,再对剩余消息做摘要,事半功倍
  2. 用便宜的模型做摘要:DeepSeek Chat 或 GPT-4o-mini 就够用,不需要用最强的模型
  3. keep.messages: 15 是黄金值:保留太多消息摘要效果不明显,保留太少上下文丢失
  4. 触发阈值不要设太低:5000 tokens 以上比较合理,太低了频繁触发,浪费 API 调用

四、成本控制层

4.1 TodoListMiddleware --- 复杂任务列清单

核心作用

当用户提出多步骤任务时,Agent 自动创建待办清单,跟踪每一步的执行进度。

为什么需要

用户说"分析这个视频 → 对比林丹的动作 → 出训练计划 → 发到邮箱",这是一个 4 步任务。如果没有清单,Agent 可能做着做着就忘了某一步,或者用户不知道"AI 做到哪了"。

配置

typescript 复制代码
todoListMiddleware()

默认配置即可工作,无需额外参数。但如果你需要自定义,可以传入配置:

typescript 复制代码
todoListMiddleware({
  // 自定义系统提示词(可选)
  // systemPrompt: "你的自定义待办清单提示词...",
})

内部原理

TodoListMiddleware 通过向系统提示词(System Prompt)中注入一段指令来工作。注入的指令大致如下:

css 复制代码
你是一个带待办清单功能的 AI 助手。

当用户提出多步骤任务时,请按以下格式创建清单:

📋 任务清单:
[⏳ 待处理] 步骤 1
[⏳ 待处理] 步骤 2
...

每完成一步,更新状态为 [✅ 已完成] 或 [🔄 进行中]。
所有步骤完成后,输出 [✅ 全部完成]。

真实场景举例

场景 1:多步骤分析

css 复制代码
用户:帮我分析这个杀球视频,然后对比林丹的杀球动作,最后出一份训练计划。

Agent 自动创建清单:
📋 任务清单:
[✅ 已完成] 1. 分析杀球视频
[🔄 进行中] 2. 查询林丹的杀球动作资料
[⏳ 待处理] 3. 对比分析
[⏳ 待处理] 4. 生成训练计划

场景 2:并行任务

css 复制代码
用户:帮我查一下明天 BWF 的赛程,同时查一下林丹的最新动态,再查一下杀球技术的训练方法。

Agent 自动创建清单:
📋 任务清单:
[🔄 进行中] 1. 查询 BWF 赛程
[🔄 进行中] 2. 查询林丹最新动态
[🔄 进行中] 3. 查询杀球训练方法

和普通 LLM 输出的区别

维度 有 TodoListMiddleware 无 TodoListMiddleware
任务跟踪 自动创建清单,更新状态 靠模型自觉,容易遗漏
用户感知 用户知道 AI 做到哪了 用户只能等最终结果
断点续做 模型知道哪些做完了,继续做剩下的 模型可能重复做已完成的步骤
复杂任务 拆解步骤,逐步执行 可能一次性处理,细节遗漏

最佳实践

  1. 默认配置就够用:不需要额外配置
  2. 放在 Summarization 后面:确保输入已经处理好,再开始列清单
  3. 对于简单任务,Agent 不会创建清单:只有多步骤复杂任务才会触发

4.2 LLMToolSelectorMiddleware --- 工具筛选

核心作用

每次用户提问时,先用一个便宜的模型快速判断需要哪些工具,只把相关工具传给主模型。

为什么需要

假设你有 20 个工具,每个工具的描述(name + description + schema)加起来可能有 5000-8000 tokens。每次调用主模型都带上所有工具描述,成本很高,而且模型需要在大量不相关的工具中做选择,准确率也会下降。

更合理的方式是:先用便宜模型"看一眼"用户问题,再决定带哪些工具

配置

typescript 复制代码
llmToolSelectorMiddleware({
  model: "openai:gpt-4o-mini",  // 便宜的筛选模型
  maxTools: 3,                   // 最多选 3 个工具
  alwaysInclude: ["searchKnowledgeTool"], // 始终携带的工具
})

参数详解

参数 说明 类型 推荐值
model 筛选模型 string "openai:gpt-4o-mini""deepseek:deepseek-chat"
maxTools 最多选几个工具 number 3-5
alwaysInclude 始终携带的工具列表 string[] 核心工具(如搜索、知识库)

工作流程

arduino 复制代码
用户问:"林丹的杀球为什么这么有威胁?"

步骤 1:GPT-4o-mini 接收所有工具描述 + 用户问题
  → 分析:这个问题需要查球员资料和杀球技术知识
  → 输出:需要工具 ["player_profile", "search_knowledge"]

步骤 2:只把这两个工具的描述传给主模型 DeepSeek
  → 节省了其他 5 个工具的 Token(约 3000-5000 tokens)

步骤 3:主模型执行
  → 调用 player_profile 获取林丹资料
  → 调用 search_knowledge 获取杀球技术分析
  → 综合回答

筛选模型的 Prompt

LangChain 内部使用的筛选 prompt 大致如下:

typescript 复制代码
// 内部使用的筛选 prompt(简化版)
const SELECTOR_PROMPT = `
你是一个工具选择器。请阅读用户的问题,判断需要调用哪些工具来完成。

可用工具:
{toolsDescription}

用户问题:{userInput}

请返回需要调用的工具名称列表(JSON 数组格式),
最多选择 {maxTools} 个工具。
如果不需要任何工具,返回空数组 []。
`;

成本对比

方案 每次调用的 Token 消耗 10,000 次调用的费用 说明
不带筛选 工具描述 ~5000 tokens × 每次 ~$15 (DeepSeek) 每次都要传所有工具描述
带筛选 筛选 ~500 tokens + 工具描述 ~1500 tokens ~ 3(GPT−4o−mini)+ 3 (GPT-4o-mini) + ~ 3(GPT−4o−mini)+ 6 (DeepSeek) = ~$9 节省约 40%

实际节省更多:因为筛选模型(GPT-4o-mini)比主模型(DeepSeek 或 GPT-4o)便宜很多,而且减少了主模型的输入 Token,也减少了主模型的输出 Token(因为工具少了,模型选择更精准)。

真实场景举例

用户问题 筛选模型判断 选中的工具 节省
"林丹的杀球为什么这么厉害?" 球员资料 + 知识库 player_profile, search_knowledge 省了 5 个工具
"今天有什么羽毛球新闻?" 新闻聚合 news_fetch 省了 6 个工具
"帮我分析这个视频" 视频分析 video_analysis 省了 6 个工具
"你好" 不需要工具 (空) 省了 7 个工具,大幅减少 Token

边界案例

案例 1:筛选模型判断错误

arduino 复制代码
问题:用户问"帮我分析这个视频的动作问题"
      筛选模型判断需要 video_analysis + coach_advice + search_knowledge
      实际上还需要 tactics_analyze(因为用户可能想战术分析)
解决方案:用 alwaysInclude 保证核心工具始终携带,
          或者调高 maxTools。

案例 2:maxTools 设得太低

makefile 复制代码
问题:maxTools: 2,但实际需要 3 个工具
      筛选模型只能选 2 个,导致缺少必要工具
解决方案:根据工具数量合理设置 maxTools,
          一般设为工具总数的 1/3 到 1/2。

最佳实践

  1. alwaysInclude 放核心工具:搜索、知识库检索等高频工具始终携带
  2. maxTools 设为工具总数的 1/3 左右:7 个工具设 3,20 个工具设 7
  3. 筛选模型选便宜的即可:GPT-4o-mini 或 DeepSeek Chat 足够
  4. 放在 ModelCallLimit 前面:先筛选出工具,再计算调用次数,更准确

4.3 ModelCallLimitMiddleware --- 模型调用次数限制

核心作用

防止 Agent 因复杂任务或死循环无限调用模型,导致 API 费用失控。

为什么需要

有一次测试中,Agent 在处理一个模糊问题时陷入了死循环------它反复调用模型,每次都生成新的工具调用,工具返回结果后又调用模型... 等我发现时,已经调了 200 多次,账单多了几十美元。

这并不是 Agent 的 bug,而是 LLM 的固有问题:当任务模糊、工具返回结果不明确时,模型倾向于"再试一次"而不是"承认失败"

配置

typescript 复制代码
modelCallLimitMiddleware({
  threadLimit: 30,       // 整个对话最多调 30 次模型
  runLimit: 8,           // 每次提问最多调 8 次模型
  exitBehavior: "end",   // 超限后优雅结束,返回已有结果
})

参数详解

参数 说明 类型 推荐值
threadLimit 整个对话(thread)的模型调用上限 number 30-50
runLimit 单次提问的模型调用上限 number 8-15
exitBehavior 超限后的行为 `"end" "error"`

threadLimit vs runLimit 的区别

erlang 复制代码
threadLimit(整个对话):
  第一轮提问:调用 5 次模型 → 累计 5 次
  第二轮提问:调用 8 次模型 → 累计 13 次
  第三轮提问:调用 8 次模型 → 累计 21 次
  第四轮提问:调用 8 次模型 → 累计 29 次
  第五轮提问:还剩 1 次 → 达到 threadLimit 30,优雅结束

runLimit(单次提问):
  第一轮提问:调用 8 次 → 达到 runLimit,结束本轮
  第二轮提问:重新计数,又调用 8 次 → 又达到 runLimit
  依此类推...

为什么需要两个维度?

  • runLimit 防止单次任务无限循环(比如一个复杂问题让模型调了 20 次)
  • threadLimit 防止整个对话无限消耗(比如用户不断追问,累计调用几百次)

exitBehavior 详解

typescript 复制代码
// exitBehavior: "end"(推荐)
// 超限后,Agent 返回已有的最佳结果,不会崩溃
modelCallLimitMiddleware({
  threadLimit: 30,
  runLimit: 8,
  exitBehavior: "end",
})
// 输出类似:
// "我已经完成了部分分析,但由于复杂度的限制,建议分步提问。"

// exitBehavior: "error"
// 超限后,抛出异常,由上层错误处理
modelCallLimitMiddleware({
  threadLimit: 30,
  runLimit: 8,
  exitBehavior: "error",
})
// 适合需要严格控制的场景,让调用方知道发生了超限

真实场景:死循环防止

arduino 复制代码
用户问:"分析一下"

这是一个模糊的请求,没有指定分析什么。
没有限制时:
  第 1 次:模型问"分析什么?" → 工具调用
  第 2 次:工具返回空 → 模型又问"请具体说明" → 工具调用
  第 3 次:工具返回空 → 模型又问... → 无限循环

有 runLimit: 8 时:
  第 1-5 次:模型尝试理解,反复调用工具
  第 6-8 次:模型继续尝试
  第 8 次后:达到 runLimit → Agent 返回"请具体说明您要分析什么"

如何选择合理的限制值

场景 threadLimit runLimit 说明
简单问答 Agent 20 5 工具少,任务简单
复杂分析 Agent(如本项目) 30 8 需要多轮工具调用
超复杂 Agent(20+ 工具) 50 15 需要大量工具协作
内部测试环境 100 20 宽松一点,方便调试

最佳实践

  1. exitBehavior: "end" 永远比 "error":用户拿到部分结果比直接报错强
  2. runLimit 设为工具数量的 1-2 倍:7 个工具设 8-15,留出多轮调用的空间
  3. threadLimit 设为 runLimit 的 3-5 倍:30 对 8 是比较合理的比例
  4. 把这个中间件放在"必装"列表第一位:这是防止账单爆炸的最后防线

4.4 ToolCallLimitMiddleware --- 工具调用次数限制

核心作用

防止某个工具或所有工具被过度调用,尤其是成本高的工具。

为什么需要

视频分析工具每次调用都需要上传视频到 Python 服务跑 YOLOv8 推理,成本高、耗时久(每次约 10-30 秒)。如果 Agent 在一个问题上反复调用视频分析,不仅浪费算力,还让用户等很久。

有些工具还有外部 API 调用次数限制(如新闻 API 每月免费额度有限),更需要严格控制。

配置

typescript 复制代码
// 单个工具限制:视频分析成本高,严格控制
toolCallLimitMiddleware({
  toolName: "videoAnalysisTool",  // 指定工具名称
  runLimit: 1,                    // 单次提问最多调用 1 次
  threadLimit: 3,                 // 整个对话最多调用 3 次
  exitBehavior: "continue",       // 超限后告诉模型别调了,其他继续
})

// 所有工具总上限:防止整体滥用
toolCallLimitMiddleware({
  runLimit: 8,                    // 每次提问所有工具加起来最多 8 次
  exitBehavior: "continue",
})

多层级限制策略

ini 复制代码
第一层:单个工具限制
  videoAnalysisTool:  runLimit=1,  threadLimit=3
  newsFetchTool:      runLimit=3,  threadLimit=20
  playerProfileTool:  runLimit=2,  threadLimit=10

第二层:所有工具总上限
  所有工具总和: runLimit=8

实际执行时:
  每次提问,先检查单个工具是否超限,再检查所有工具总和是否超限。
  两者都通过,才允许调用。

不同工具的限流策略推荐

工具类型 runLimit threadLimit 原因
视频分析 1 3 成本高,耗时久
新闻聚合 3 20 外部 API 有免费额度
球员资料查询 2 10 外部 API 有频率限制
知识库检索 不限 不限 本地检索,成本低
训练计划生成 1 5 高风险操作,需要审批
战术分析 2 10 计算密集,但可以接受

exitBehavior 的区别

typescript 复制代码
// exitBehavior: "continue"
// 超限后,告诉模型"这个工具不能调了,但其他工具和模型继续"
// 适用于:某个工具超限,但其他任务还可以继续
toolCallLimitMiddleware({
  toolName: "videoAnalysisTool",
  runLimit: 1,
  exitBehavior: "continue",
})
// 效果:视频分析不能调了,但 Agent 还可以继续回答用户

// exitBehavior: "end"
// 超限后,直接结束本轮执行
// 适用于:所有工具总上限,超限说明整体消耗过大
toolCallLimitMiddleware({
  runLimit: 8,
  exitBehavior: "end",
})
// 效果:Agent 停止执行,返回已有结果

真实场景

scss 复制代码
用户说:"分析这 5 个视频,对比一下动作差异。"

第 1 次调用:videoAnalysisTool(视频 1)→ 成功
第 2 次调用:videoAnalysisTool(视频 2)→ 达到 runLimit(1) → 被拒绝

Agent 告诉用户:
"我每次最多分析 1 个视频。视频 1 的分析结果是:
[杀球角度偏大,起跳高度不够...]
要继续分析下一个视频吗?"

和 ModelCallLimitMiddleware 的区别

维度 ModelCallLimit ToolCallLimit
限制对象 模型调用次数 工具调用次数
作用范围 全局(所有模型调用) 按工具或全局
超限原因 死循环、复杂任务 工具滥用、高频调用
配置粒度 thread + run toolName + run + thread

最佳实践

  1. 先限制单个工具,再限制总上限:两个 ToolCallLimitMiddleware 叠加使用
  2. 成本高的工具设低限制:视频分析设 1,知识库检索可以不限
  3. exitBehavior: "continue" 用户体验更好:告诉用户"这个不行了,其他的还可以"
  4. 放在 ModelCallLimit 后面:先检查模型调用次数,再检查工具调用次数

五、稳定性层

5.1 ModelRetryMiddleware --- 模型调用失败重试

核心作用

模型调用失败时,自动重试,支持指数退避 + 抖动(jitter),避免雪崩。

为什么需要

大模型 API 不是 100% 可靠的。以下是生产环境中常见的失败原因:

错误类型 HTTP 状态码 频率 原因
Rate Limit(限流) 429 请求频率超限
Timeout(超时) 网络延迟或模型响应慢
Server Error(服务端错误) 500/502/503 模型供应商宕机
Authentication Error(鉴权) 401 API Key 过期
Context Length(超长) 400 输入超出模型上下文窗口

如果没有重试机制,任何一次临时故障都直接导致用户请求失败。

配置

typescript 复制代码
modelRetryMiddleware({
  maxRetries: 2,                    // 最多重试 2 次
  retryOn: [RateLimitError, TimeoutError], // 只重试特定错误
  backoffFactor: 2,                 // 指数退避因子
  initialDelayMs: 1000,             // 首次重试等待 1 秒
  maxDelayMs: 30000,                // 最长等待 30 秒
  jitter: true,                     // 加随机抖动,防止惊群效应
  onFailure: "continue",            // 重试都失败后,继续执行
})

参数详解

参数 说明 类型 默认值 推荐值
maxRetries 最大重试次数 number 2 2-3
retryOn 哪些错误类型需要重试 `Error[] Function` 所有错误
backoffFactor 退避因子 number 2 2
initialDelayMs 首次等待时间 number 1000 1000
maxDelayMs 最长等待时间 number 60000 30000
jitter 是否加随机抖动 boolean true true
onFailure 重试都失败后的行为 `"continue" "error" Function`

指数退避算法详解

ini 复制代码
// 无抖动的退避
delay_n = initialDelay × (backoffFactor ^ n-1)

// 有抖动的退避(Full Jitter)
delay_n = random(0, initialDelay × (backoffFactor ^ n-1))

// 实际计算:
// initialDelay = 1000ms, backoffFactor = 2

第 1 次重试:delay = random(0, 1000)      → 0-1000ms
第 2 次重试:delay = random(0, 2000)      → 0-2000ms
第 3 次重试:delay = random(0, 4000)      → 0-4000ms

为什么需要抖动(Jitter)

假设 100 个请求同时遇到限流(429),如果没有抖动,它们会同时等待 1 秒后一起重试,把 API 服务器打得比之前更惨------这就是惊群效应(Thundering Herd Problem)

加上抖动后,100 个请求的等待时间分布在 0-1000ms 之间,重试请求均匀分布,给服务器恢复的时间。

retryOn 的两种配置方式

typescript 复制代码
// 方式一:指定错误类型
modelRetryMiddleware({
  retryOn: [RateLimitError, TimeoutError],
})

// 方式二:自定义判断函数
modelRetryMiddleware({
  retryOn: (error: Error) => {
    // 只重试 429 和 5xx 错误
    if (error.name === "HTTPError" && "statusCode" in error) {
      const statusCode = (error as any).statusCode;
      return statusCode === 429 || (statusCode >= 500 && statusCode < 600);
    }
    return false;
  },
})

真实场景

arduino 复制代码
正常的模型调用:
  → 发送请求
  → 等待 1.5s
  → 收到响应(200 OK)
  → 完成

遇到限流(429):
  → 发送请求
  → 收到 429 Rate Limit Exceeded
  → 等待 847ms(1000ms × random)
  → 重试请求
  → 收到 200 OK
  → 完成(用户无感知,只是多等了 0.8 秒)

遇到超时:
  → 发送请求
  → 等待 30s(超时)
  → 重试请求
  → 等待 1.6s(2000ms × random)
  → 重试请求
  → 又超时
  → 再次重试
  → 等待 3.2s(4000ms × random)
  → 成功
  → 完成(用户多等了约 35 秒,但请求没失败)

全部失败:
  → 重试 2 次都失败
  → 执行 onFailure: "continue"
  → 返回错误消息给用户:模型暂时不可用,请稍后重试
  → Agent 不崩溃,对话继续

最佳实践

  1. retryOn 只重试可恢复的错误:限流、超时重试;鉴权错误、无效请求不重试
  2. maxRetries: 2 就够了:重试 3 次以上收益递减,且用户等待时间太长
  3. jitter: true 一定要开:惊群效应在高并发下非常严重
  4. onFailure: "continue""error":用户拿到友好的错误提示,比 Agent 直接崩溃好

5.2 ModelFallbackMiddleware --- 模型容灾切换

核心作用

主模型挂了,自动切换到备用模型,用户无感知,服务不中断。

为什么需要

任何模型供应商都可能出问题------限流、宕机、网络故障、API 变更。如果你的 Agent 只绑定一个模型,那就是单点故障。ModelFallbackMiddleware 让你可以配置一个"备胎"模型,主模型挂了自动切过去。

配置

typescript 复制代码
modelFallbackMiddleware("openai:gpt-4o-mini")

工作原理

复制代码
正常情况:
  DeepSeek → 正常返回 → 继续执行
  (全程使用主模型)

DeepSeek 挂了:
  DeepSeek → 调用失败(429/500/超时)
  → 自动切换到 GPT-4o-mini
  → 继续执行
  (用户不知道后面换了模型,体验无中断)

DeepSeek 恢复后:
  → 下一次提问,重新尝试主模型 DeepSeek
  → 如果正常,切回主模型
  → 如果又挂了,再切到备用模型

和 ModelRetryMiddleware 的协作

复制代码
执行流程:
第 1 次:尝试 DeepSeek → 失败
第 2 次:ModelRetry 重试 DeepSeek → 失败
第 3 次:ModelRetry 再次重试 DeepSeek → 失败
→ ModelFallback 介入:切换到 GPT-4o-mini
第 4 次:用 GPT-4o-mini 执行 → 成功

协作原则:先重试(ModelRetry),后容灾(ModelFallback)

选择备用模型的策略

主模型 推荐备用模型 原因
DeepSeek (deepseek-chat) GPT-4o-mini 便宜,能力足够
GPT-4o GPT-4o-mini 同一生态,兼容性好
Claude Sonnet Claude Haiku 同一生态,便宜
自建模型 GPT-4o-mini 外部备用,避免单点故障

最佳实践

  1. ModelRetry 放在 ModelFallback 前面:先重试,不行再切
  2. 备用模型要便宜:容灾场景下,功能可用性比质量更重要
  3. 备用模型最好不同供应商:避免同一供应商的多个模型同时宕机
  4. 建议日志记录切换事件:方便监控和排查

5.3 ToolRetryMiddleware --- 工具调用失败重试

核心作用

工具调用失败时自动重试,可配置仅对特定工具生效。

为什么需要

工具调用失败的原因很多------外部 API 超时、网络波动、数据库连接失败。但并不是所有工具都需要重试。比如:

工具类型 失败原因 是否重试 原因
新闻聚合(外部 API) 网络超时 ✅ 重试 可能是临时网络问题
球员资料查询(外部 API) 服务端错误 ✅ 重试 可能是对方临时宕机
知识库检索(本地) 数据库连接失败 ❌ 不重试 代码逻辑问题,重试没用
视频分析(本地) 文件不存在 ❌ 不重试 输入错误,重试没用

配置

typescript 复制代码
toolRetryMiddleware({
  // 只对特定工具重试
  tools: ["newsFetchTool", "playerProfileTool"],
  // 如果不指定 tools,则对所有工具重试

  maxRetries: 2,           // 最多重试 2 次
  retryOn: [TimeoutError, NetworkError], // 只重试网络类错误
  backoffFactor: 2,        // 指数退避
  initialDelayMs: 1000,
  maxDelayMs: 30000,
  jitter: true,
  onFailure: "continue",   // 都失败后继续执行
})

和 ModelRetryMiddleware 的对比

维度 ModelRetry ToolRetry
重试对象 模型调用(LLM API) 工具调用(Tool execution)
失败原因 模型限流、超时、宕机 外部 API 超时、网络错误、数据库连接失败
配置差异 tools 参数 tools 参数,可指定工具
重试策略 相同(指数退避 + jitter) 相同
典型场景 DeepSeek 返回 429 newsFetchTool 请求超时

真实场景

复制代码
新闻聚合工具调用:
第 1 次:请求 newsapi.org → 超时(30s)
第 2 次:重试 → 等待 1.2s → 请求 → 超时
第 3 次:重试 → 等待 2.5s → 请求 → 成功
→ 返回新闻数据,Agent 继续执行

球员资料工具调用:
第 1 次:请求 Wikipedia API → 返回 500
第 2 次:重试 → 等待 0.8s → 请求 → 返回 200
→ 返回球员资料,Agent 继续执行

最佳实践

  1. 只对外部 API 工具重试:本地工具失败重试没意义
  2. retryOn 只选网络类错误:TimeoutError、NetworkError,不重试业务错误
  3. 工具重试的超时时间要设置:不然一次重试可能等很久
  4. 建议和 ModelRetryMiddleware 使用相同的退避策略:保持一致

六、合规风控层

6.1 HumanInTheLoopMiddleware --- 高风险操作人工审批

核心作用

Agent 在执行高风险操作前暂停,等待人工审批通过后再执行。

为什么需要

训练计划涉及运动安全------如果 AI 给一个初学者生成了一个高强度的训练计划,用户照着练受伤了,责任算谁的?

这不是技术问题,是合规和法律责任问题。HumanInTheLoopMiddleware 让 Agent 在生成训练计划之前"停下来问一句",等用户确认后再执行,把这个"责任阀门"交给人而不是 AI。

配置

typescript 复制代码
humanInTheLoopMiddleware({
  interruptOn: {
    // 对 trainingPlanTool 工具的调用进行审批
    trainingPlanTool: {
      // 允许的审批决策
      allowedDecisions: ["approve", "edit", "reject"],

      // 审批界面描述(可以动态获取 toolCall 的参数)
      description: (toolCall) => `
🏸 **训练计划审批**

用户水平:${toolCall.args.level}
训练目标:${toolCall.args.goal}
每周训练:${toolCall.args.frequency} 次

⚠️ 训练计划涉及运动强度,请确认计划合理后再批准。

[✅ 批准] 直接生成计划
[✏️ 编辑] 修改参数后生成
[❌ 拒绝] 不生成计划
      `.trim(),
    },
  },
})

支持多个工具的审批

typescript 复制代码
humanInTheLoopMiddleware({
  interruptOn: {
    // 训练计划生成需要审批
    trainingPlanTool: {
      allowedDecisions: ["approve", "edit", "reject"],
      description: (toolCall) => `训练计划审批...`,
    },

    // 发送邮件也需要审批
    sendEmailTool: {
      allowedDecisions: ["approve", "reject"],
      description: (toolCall) => `邮件发送审批...`,
    },

    // 删除数据更是必须审批
    deleteDataTool: {
      allowedDecisions: ["approve", "reject"],
      description: (toolCall) => `数据删除审批...`,
    },
  },
})

三种审批决策详解

决策 行为 前端实现 适用场景
approve 批准,Agent 继续执行 点击"批准"按钮 参数合理,直接生成
edit 编辑参数后批准 弹出编辑表单,修改后提交 训练频率太高,调低一点
reject 拒绝,Agent 停止执行 点击"拒绝"按钮 不需要训练计划

真实交互流程

arduino 复制代码
用户:帮我生成一个训练计划,我是初级水平,每周练 5 次,目标是提高杀球

Agent 暂停执行 → 返回审批请求给前端:

  ┌──────────────────────────────────────────────────┐
  │  🏸 训练计划审批                                  │
  │                                                   │
  │  用户水平:初级                                   │
  │  训练目标:提高杀球                               │
  │  每周训练:5 次                                   │
  │                                                   │
  │  ⚠️ 训练计划涉及运动强度,                        │
  │  请确认计划合理后再批准。                         │
  │                                                   │
  │  ┌──────────┐  ┌──────────┐  ┌──────────┐       │
  │  │ ✅ 批准   │  │ ✏️ 编辑   │  │ ❌ 拒绝   │       │
  │  └──────────┘  └──────────┘  └──────────┘       │
  └──────────────────────────────────────────────────┘

用户点击"编辑" → 修改参数:
  {
    level: "初级",
    goal: "提高杀球",
    frequency: 3  ← 从 5 改成 3
  }

用户提交编辑 → Agent 收到审批通过 → 继续执行 → 生成训练计划

最终输出:
  "根据您的需求,已生成训练计划。建议每周训练 3 次..."

审批超时处理

生产环境中,用户可能长时间不审批。建议前端配合实现超时机制:

typescript 复制代码
// 前端实现审批超时
const APPROVAL_TIMEOUT = 5 * 60 * 1000; // 5 分钟

// 如果用户 5 分钟内没有审批,自动拒绝
setTimeout(() => {
  rejectApproval("审批超时,已自动拒绝");
}, APPROVAL_TIMEOUT);

适用场景

场景 安全级别 推荐审批决策 示例
训练计划 ⚠️ 中 approve / edit / reject 运动安全
自动转账 🚨 高 approve / reject 金融交易
删除数据 🚨 高 approve / reject 数据安全
发送邮件 ⚠️ 中 approve / edit / reject 内容合规
修改配置 ⚠️ 中 approve / reject 系统安全
日常查询 ✅ 低 不需要审批 信息检索

最佳实践

  1. 只对高风险操作加审批:不要对所有工具都加,否则用户体验很差
  2. allowedDecisions 最少包含 approve + reject:edit 是可选的
  3. 审批描述要清晰:让审批人一眼看懂"这是什么操作,风险是什么"
  4. 前端配合实现超时自动拒绝:避免审批请求一直挂着
  5. 放在中间件链的最后:前面所有中间件都处理完了,再等人工审批

七、中间件执行顺序的"潜规则"

7.1 为什么顺序这么重要?

中间件的执行顺序至关重要。换个顺序,效果可能完全不同,甚至引发 bug。

7.2 正确顺序 vs 错误顺序

erlang 复制代码
正确的顺序:
输入 → PII → ContextEditing → Summarization → TodoList → ToolSelector → 
ModelCallLimit → ModelRetry → ModelFallback → ToolCallLimit → ToolRetry → HITL → 模型

错误的顺序(反面案例 1):
输入 → ToolSelector → PII → ...

反面案例 1:ToolSelector 在 PII 前面

perl 复制代码
用户输入:"我的邮箱是 abc@example.com,帮我查一下林丹的资料"
→ ToolSelector(GPT-4o-mini)先看到明文邮箱 → 等于脱敏白做了
→ 敏感信息被传到了第三方筛选模型

反面案例 2:HITL 在 Summarization 前面

arduino 复制代码
用户要求生成训练计划
→ HITL 先暂停等审批
→ 用户审批通过
→ Summarization 触发 → 把审批通过的上下文摘要掉了
→ Agent 丢失了"审批已通过"的信息 → 重新要求审批 → 死循环

7.3 四大核心原则

  1. 输入处理先于业务逻辑:PII → ContextEditing → Summarization → TodoList(先处理好输入,再干活)
  2. 筛选先于限制:ToolSelector → ModelCallLimit(先筛选出需要的工具,再计算调用次数,更准确)
  3. 重试先于容灾:ModelRetry → ModelFallback(先重试几次,确实不行再切备用模型)
  4. 限制先于审批:ToolCallLimit → HITL(先确保调用次数没超限,再等人工审批)

7.4 如果中间件太多记不住顺序怎么办?

记住四个"先于"原则即可:

复制代码
输入先处理 → 筛选先限制 → 重试先容灾 → 限制先审批

八、完整配置一览

把 11 个中间件整合到 Agent 中的完整代码:

typescript 复制代码
import { createAgent, piiMiddleware, summarizationMiddleware, 
         todoListMiddleware, llmToolSelectorMiddleware, 
         modelCallLimitMiddleware, toolCallLimitMiddleware, 
         modelFallbackMiddleware, humanInTheLoopMiddleware,
         contextEditingMiddleware, ClearToolUsesEdit,
         modelRetryMiddleware, toolRetryMiddleware } from 'langchain';
import { MemorySaver } from '@langchain/langgraph-checkpoint';
import { chatModel, summarizerModel } from './models.js';

const checkpointer = new MemorySaver();

export const badmintonAgent = createAgent({
  model: chatModel,          // DeepSeek Chat
  tools: [/* 7 个工具 */],
  checkpointer,
  middleware: [
    // ========== 输入安全层 ==========

    // 1. 输入脱敏
    piiMiddleware("email", { strategy: "redact", applyToInput: true }),
    piiMiddleware("url", { strategy: "redact", applyToInput: true }),

    // 2. 清理旧工具结果
    contextEditingMiddleware({
      edits: [new ClearToolUsesEdit({
        trigger: { tokens: 100000 },
        keep: { messages: 3 },
      })],
    }),

    // 3. 长对话摘要
    summarizationMiddleware({
      model: summarizerModel,
      trigger: { tokens: 5000 },
      keep: { messages: 15 },
    }),

    // ========== 成本控制层 ==========

    // 4. 任务清单
    todoListMiddleware(),

    // 5. 工具筛选
    llmToolSelectorMiddleware({
      model: "openai:gpt-4o-mini",
      maxTools: 3,
      alwaysInclude: ["searchKnowledgeTool"],
    }),

    // 6. 模型调用限制
    modelCallLimitMiddleware({
      threadLimit: 30,
      runLimit: 8,
    }),

    // ========== 稳定性层 ==========

    // 7. 模型重试
    modelRetryMiddleware({ maxRetries: 2 }),

    // 8. 模型容灾
    modelFallbackMiddleware("openai:gpt-4o-mini"),

    // ========== 成本控制层(续) ==========

    // 9. 工具调用限制
    toolCallLimitMiddleware({ toolName: "videoAnalysisTool", runLimit: 1, threadLimit: 3 }),
    toolCallLimitMiddleware({ runLimit: 8 }),

    // 10. 工具重试(仅外部 API)
    toolRetryMiddleware({
      maxRetries: 2,
      tools: ["newsFetchTool", "playerProfileTool"],
    }),

    // ========== 合规风控层 ==========

    // 11. 人工审批
    humanInTheLoopMiddleware({
      interruptOn: {
        trainingPlanTool: {
          allowedDecisions: ["approve", "edit", "reject"],
          description: (toolCall) => `
🏸 **训练计划审批**

用户水平:${toolCall.args.level}
训练目标:${toolCall.args.goal}
每周训练:${toolCall.args.frequency} 次

⚠️ 训练计划涉及运动强度,请确认计划合理后再批准。

[✅ 批准] 直接生成计划
[✏️ 编辑] 修改参数后生成
[❌ 拒绝] 不生成计划
          `.trim(),
        },
      },
    }),
  ],
})

九、常见问题排查(FAQ)

9.1 中间件不生效

问题:配置了中间件,但运行起来感觉没效果。

排查步骤

  1. 检查中间件是否在 middleware: [...] 数组中
  2. 检查中间件顺序是否正确(特别是 PII 是否放在最前面)
  3. 检查配置参数是否正确(如 applyToInput: true 是否设置)
  4. 查看 LangSmith Trace,确认中间件是否被调用

9.2 中间件冲突

问题:两个中间件配置冲突,导致异常行为。

常见冲突

  • PII redact 了 URL,但视频分析工具需要 URL → 视频分析无法工作
  • Summarization 摘要掉了 HITL 的审批结果 → 重复要求审批

解决方案

  • 仔细检查中间件之间的交互
  • excludeTools 等参数避免冲突
  • 保持中间件顺序正确

9.3 性能问题

问题:加了中间件后,Agent 响应变慢。

性能开销排名

  1. Summarization(需要调用模型做摘要)
  2. LLMToolSelector(需要调用筛选模型)
  3. ContextEditing(需要遍历消息计算 Token)
  4. 其他中间件(几乎无开销)

优化建议

  • 降低 Summarization 的触发频率
  • 用更便宜的模型做摘要和筛选
  • tokenCountMethod: "approx" 加速计数

十、总结

10.1 中间件优先级矩阵

中间件 一句话 优先级 必装? 学习成本
PIIMiddleware 用户输入脱敏,防隐私泄露 ★★★★★ 必装
ContextEditingMiddleware 清理旧工具结果,省 Token ★★★★ 建议装 ⭐⭐⭐
SummarizationMiddleware 长对话压缩摘要,省上下文 ★★★★ 建议装 ⭐⭐
TodoListMiddleware 复杂任务列清单,防遗漏 ★★★ 可选
LLMToolSelectorMiddleware 便宜模型筛选工具,省费用 ★★★★ 建议装 ⭐⭐
ModelCallLimitMiddleware 防模型调用死循环,控成本 ★★★★★ 必装
ModelRetryMiddleware 模型调用失败重试,提稳定性 ★★★★ 建议装 ⭐⭐
ModelFallbackMiddleware 模型容灾切换,防单点故障 ★★★★ 建议装
ToolCallLimitMiddleware 防工具滥用,控成本 ★★★★ 建议装 ⭐⭐
ToolRetryMiddleware 工具调用失败重试,提稳定性 ★★★ 可选 ⭐⭐
HumanInTheLoopMiddleware 高风险操作人工审批,合规风控 ★★★ 按需装 ⭐⭐⭐

10.2 生产级最小必要组合

如果你的 Agent 要上线到生产环境,至少需要这三个:

scss 复制代码
PIIMiddleware + ModelCallLimitMiddleware + (ModelRetryMiddleware | ModelFallbackMiddleware)
  • PII:保护用户隐私,合规底线
  • ModelCallLimit:保护你的钱包,防止账单爆炸
  • ModelRetry 或 ModelFallback:保护服务稳定性,防止单点故障

10.3 推荐的学习路径

如果你是第一次接触 Agent 中间件,建议按以下顺序学习和实践:

  1. 先上 PII + ModelCallLimit(5 分钟配好,立刻见效)
  2. 再加 ModelRetry + ModelFallback(提升稳定性)
  3. 再加 Summarization + ContextEditing(处理长对话)
  4. 再加 ToolCallLimit + ToolRetry(精细控制工具调用)
  5. 最后加 LLMToolSelector + TodoList + HITL(锦上添花)

十一、写在最后

这篇文章的所有代码都来自我的开源项目 Badminton Agent------一个基于 LangChain 的羽毛球 AI 助手。项目已完整实现上述 11 个中间件的配置和集成。

如果你正在构建自己的 Agent,希望这篇文章能给你一些参考。中间件不是越多越好,而是要理解每个中间件解决的问题,按需选用

有任何问题欢迎在评论区交流。如果觉得有帮助,请点赞收藏,让更多人看到。


如觉得本文对你有帮助的话,欢迎点赞❤❤❤,写作不易,持续输出的背后是无数个日夜的积累,您的点赞是持续写作的动力,感谢支持!有什么写错的,需要增加的可以在评论区留言 谢谢大家支持

相关推荐
BraveWang2 小时前
【LangChain 1.x】13、安全护栏|PII 脱敏、自定义护栏与多层叠加防护
langchain
灵极海2 小时前
LangChain4j RAG 实战完整指南:从入门到踩坑
java·langchain
DigitalOcean2 小时前
Claude Opus 5 现已上线 DigitalOcean AI 推理云
agent
沉默王二3 小时前
腾讯面试官:“你说你做了一个终端Agent,那说说 LLM 和 Agent的区别,ReAct、MCP、Tool、Memory、Skills?”我信誓旦旦开始背了
面试·agent·腾讯
后端小肥肠4 小时前
做个人 IP 不用真人出镜,我做了个一键生成 IP 动画视频的 Skill
人工智能·aigc·agent
uncle_ll4 小时前
LangGraph 深度解析:用图结构构建下一代智能代理与多智能体系统
langchain·llm·agent·graph·langgraph
武子康4 小时前
从世界状态到可执行控制:Cosmos 3 Edge 与机器人控制器之间应建立什么合同
人工智能·agent·nvidia
前端开发江鸟4 小时前
我以为 SSE 渲染就是 Agent 流式,直到它停在用户确认前
agent
阿里云云原生5 小时前
告别“玄学”调参:如何让 Agent 从真实执行路径中自动挖掘优化经验?
agent