一个生产级 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],帮我查一下
这是正确的行为。但如果用户说的是"请联系我之前的教练",教练的名字恰好类似邮箱格式,就需要考虑是否要调整检测规则。
最佳实践
- 不要对所有 PII 类型一刀切:根据业务场景选择性启用
applyToInput: true就够了:通常不需要对 AI 输出和工具结果做脱敏- URL 脱敏要谨慎:如果 Agent 需要处理链接,URL 脱敏可能影响功能
- 测试你的正则:用各种边界情况测试 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 条消息 |
| 信息损失 | 完全丢失工具结果内容 | 摘要保留了关键信息 |
| 适用场景 | 清理大块无用数据 | 压缩仍有价值的对话 |
| 执行成本 | 几乎为零(纯内存操作) | 需要调用模型做摘要 |
| 推荐搭配 | 先清理,后摘要 | 先清理减少数据量,再摘要更省钱 |
最佳实践
- ContextEditing 放在 Summarization 前面:先清理掉大块无用数据,再对剩余数据做摘要,更省钱
- 用
excludeTools保护重要工具:知识库检索结果等需要保留的工具,不要被清理 clearToolInputs: false:保留工具调用参数方便追溯调试- 百分比触发方式最灵活 :
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 中指定格式要求。
最佳实践
- 搭配 ContextEditing 使用:先清理旧工具结果,再对剩余消息做摘要,事半功倍
- 用便宜的模型做摘要:DeepSeek Chat 或 GPT-4o-mini 就够用,不需要用最强的模型
keep.messages: 15是黄金值:保留太多消息摘要效果不明显,保留太少上下文丢失- 触发阈值不要设太低: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 做到哪了 | 用户只能等最终结果 |
| 断点续做 | 模型知道哪些做完了,继续做剩下的 | 模型可能重复做已完成的步骤 |
| 复杂任务 | 拆解步骤,逐步执行 | 可能一次性处理,细节遗漏 |
最佳实践
- 默认配置就够用:不需要额外配置
- 放在 Summarization 后面:确保输入已经处理好,再开始列清单
- 对于简单任务,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)+ 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。
最佳实践
alwaysInclude放核心工具:搜索、知识库检索等高频工具始终携带maxTools设为工具总数的 1/3 左右:7 个工具设 3,20 个工具设 7- 筛选模型选便宜的即可:GPT-4o-mini 或 DeepSeek Chat 足够
- 放在 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 | 宽松一点,方便调试 |
最佳实践
exitBehavior: "end"永远比"error"好:用户拿到部分结果比直接报错强- runLimit 设为工具数量的 1-2 倍:7 个工具设 8-15,留出多轮调用的空间
- threadLimit 设为 runLimit 的 3-5 倍:30 对 8 是比较合理的比例
- 把这个中间件放在"必装"列表第一位:这是防止账单爆炸的最后防线
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 |
最佳实践
- 先限制单个工具,再限制总上限:两个 ToolCallLimitMiddleware 叠加使用
- 成本高的工具设低限制:视频分析设 1,知识库检索可以不限
exitBehavior: "continue"用户体验更好:告诉用户"这个不行了,其他的还可以"- 放在 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 不崩溃,对话继续
最佳实践
retryOn只重试可恢复的错误:限流、超时重试;鉴权错误、无效请求不重试maxRetries: 2就够了:重试 3 次以上收益递减,且用户等待时间太长jitter: true一定要开:惊群效应在高并发下非常严重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 | 外部备用,避免单点故障 |
最佳实践
- ModelRetry 放在 ModelFallback 前面:先重试,不行再切
- 备用模型要便宜:容灾场景下,功能可用性比质量更重要
- 备用模型最好不同供应商:避免同一供应商的多个模型同时宕机
- 建议日志记录切换事件:方便监控和排查
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 继续执行
最佳实践
- 只对外部 API 工具重试:本地工具失败重试没意义
retryOn只选网络类错误:TimeoutError、NetworkError,不重试业务错误- 工具重试的超时时间要设置:不然一次重试可能等很久
- 建议和 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 | 系统安全 |
| 日常查询 | ✅ 低 | 不需要审批 | 信息检索 |
最佳实践
- 只对高风险操作加审批:不要对所有工具都加,否则用户体验很差
allowedDecisions最少包含 approve + reject:edit 是可选的- 审批描述要清晰:让审批人一眼看懂"这是什么操作,风险是什么"
- 前端配合实现超时自动拒绝:避免审批请求一直挂着
- 放在中间件链的最后:前面所有中间件都处理完了,再等人工审批
七、中间件执行顺序的"潜规则"
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 四大核心原则
- 输入处理先于业务逻辑:PII → ContextEditing → Summarization → TodoList(先处理好输入,再干活)
- 筛选先于限制:ToolSelector → ModelCallLimit(先筛选出需要的工具,再计算调用次数,更准确)
- 重试先于容灾:ModelRetry → ModelFallback(先重试几次,确实不行再切备用模型)
- 限制先于审批: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 中间件不生效
问题:配置了中间件,但运行起来感觉没效果。
排查步骤:
- 检查中间件是否在
middleware: [...]数组中 - 检查中间件顺序是否正确(特别是 PII 是否放在最前面)
- 检查配置参数是否正确(如
applyToInput: true是否设置) - 查看 LangSmith Trace,确认中间件是否被调用
9.2 中间件冲突
问题:两个中间件配置冲突,导致异常行为。
常见冲突:
- PII redact 了 URL,但视频分析工具需要 URL → 视频分析无法工作
- Summarization 摘要掉了 HITL 的审批结果 → 重复要求审批
解决方案:
- 仔细检查中间件之间的交互
- 用
excludeTools等参数避免冲突 - 保持中间件顺序正确
9.3 性能问题
问题:加了中间件后,Agent 响应变慢。
性能开销排名:
- Summarization(需要调用模型做摘要)
- LLMToolSelector(需要调用筛选模型)
- ContextEditing(需要遍历消息计算 Token)
- 其他中间件(几乎无开销)
优化建议:
- 降低 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 中间件,建议按以下顺序学习和实践:
- 先上 PII + ModelCallLimit(5 分钟配好,立刻见效)
- 再加 ModelRetry + ModelFallback(提升稳定性)
- 再加 Summarization + ContextEditing(处理长对话)
- 再加 ToolCallLimit + ToolRetry(精细控制工具调用)
- 最后加 LLMToolSelector + TodoList + HITL(锦上添花)
十一、写在最后
这篇文章的所有代码都来自我的开源项目 Badminton Agent------一个基于 LangChain 的羽毛球 AI 助手。项目已完整实现上述 11 个中间件的配置和集成。
如果你正在构建自己的 Agent,希望这篇文章能给你一些参考。中间件不是越多越好,而是要理解每个中间件解决的问题,按需选用。
有任何问题欢迎在评论区交流。如果觉得有帮助,请点赞收藏,让更多人看到。
如觉得本文对你有帮助的话,欢迎点赞❤❤❤,写作不易,持续输出的背后是无数个日夜的积累,您的点赞是持续写作的动力,感谢支持!有什么写错的,需要增加的可以在评论区留言 谢谢大家支持