
很多团队把 PII 脱敏、历史压缩、人工确认这些逻辑全塞进一个超长 system prompt,或者散落在业务函数里。结果代码改不动、测不了,顺序一乱就出 bug。这篇聊中间件(middleware)——一类在请求进入模型前、响应离开模型后做横切处理的机制。它不是 prompt 里的杂烩,而是有顺序、可组合的管道。我们讲透三个典型中间件:PII 过滤、对话摘要压缩、人工介入(HITL)。

一句话:中间件是在请求进模型前、响应出模型后做横切处理的管道节点。它和 Web 框架里的 middleware、Spring 的拦截器是同构的东西——横切关注点(cross-cutting concern)不该写进业务主干,应该抽出来挂在请求-响应的路径上。
一个普通函数调用是 输入 → 函数 → 输出,中间件把它变成 输入 → 中间件A → 中间件B → 模型 → 中间件B' → 中间件A' → 输出。注意请求方向和响应方向是两趟独立的钩子:process_request 在模型前,process_response 在模型后。
这跟"把规则写进 prompt"本质不同。prompt 里写"请勿泄露隐私",模型听不听是两码事;中间件在管道里强制把明文换成占位符,模型这辈子都看不到原始 PII。管道是确定性的工程控制,prompt 只是软约束。
下面这段是中间件的最小契约和一个按正序进、逆序出的管道实现,后面三个中间件都挂在它上面。
from typing import Callable, Protocol
# 一次请求-响应的上下文,中间件可读写它
class MiddlewareContext:
def __init__(self, messages, metadata=None):
self.messages = messages # 送给模型的消息列表
self.metadata = metadata or {} # 中间件之间的私人数据
self.response = None # 模型返回后填充
# 中间件契约:请求钩子 + 响应钩子
class Middleware(Protocol):
def process_request(self, ctx: MiddlewareContext) -> MiddlewareContext: ...
def process_response(self, ctx: MiddlewareContext) -> MiddlewareContext: ...
# 管道:请求正序穿过,响应逆序返回
class MiddlewarePipeline:
def __init__(self, middlewares: list, model_fn: Callable):
self.middlewares = middlewares
self.model_fn = model_fn
def run(self, ctx: MiddlewareContext) -> MiddlewareContext:
for mw in self.middlewares: # 请求方向:正序
ctx = mw.process_request(ctx)
ctx.response = self.model_fn(ctx.messages, ctx.metadata) # 调模型
for mw in reversed(self.middlewares): # 响应方向:逆序
ctx = mw.process_response(ctx)
return ctx逆序返回这一点很重要:PII 脱敏在请求方向第一个把明文换掉,响应方向它就最后一个把占位符换回来——这样中间所有环节(包括模型)看到的都是脱敏后的内容。顺序即语义。
PII(个人身份信息)过滤要解决一个具体问题:用户输入里常有手机号、身份证、邮箱,你不能把它们送进第三方模型,但又得把答案里的真实值返回给用户。解法是两端脱敏-恢复:请求方向把敏感字段替换成占位符 <PII_0>,把映射存在上下文里随请求带走;响应方向再把占位符还原。

这样模型自始至终看不到明文,合规面和泄露面都大幅缩小。占位符到原文的映射绝不能写进日志,也绝不能进模型的上下文——它只活在中件间的私有 metadata 里。
import re
class PIIMaskMiddleware:
def __init__(self):
self.store = {} # token -> 原文,真实系统里按 session 隔离
def process_request(self, ctx):
masked, mapping = self._mask(ctx.messages)
ctx.messages = masked
ctx.metadata["pii_map"] = mapping # 随请求一起带走
return ctx
def process_response(self, ctx):
mapping = ctx.metadata.pop("pii_map", {})
ctx.response = self._unmask(ctx.response, mapping)
return ctx
def _mask(self, messages):
mapping, counter = {}, 0
def replace(m):
nonlocal counter
token = f"<PII_{counter}>"
mapping[token] = m.group(0)
counter += 1
return token
for msg in messages:
msg["content"] = re.sub(r"1[3-9]\d{9}", replace, msg["content"]) # 手机号
return messages, mapping
def _unmask(self, text, mapping):
for token, original in mapping.items():
text = text.replace(token, original)
return text几个工程要点:
<PII_n> 比固定字符串安全,避免不同用户的 PII 串台多轮对话一久,历史消息会无限涨,token 直接爆表,还挤占模型的有效注意力。摘要压缩中间件干的事是:当历史超过阈值,把最旧的若干条交给一个压缩模型生成摘要,用一条摘要消息替换它们。

这是有损降维,不是无损存储。设计时必须回答三个问题:压哪些(通常最旧的)、留哪些(最近的 Few-shot 和当前轮)、压完往哪放(压缩结果要写回持久化历史,否则下一轮又从头涨)。
class SummaryCompressionMiddleware:
def __init__(self, max_messages: int, summarizer_fn):
self.max_messages = max_messages # 保留的最近消息条数
self.summarizer = summarizer_fn # 调用压缩模型的函数
def process_request(self, ctx):
msgs = ctx.messages
if len(msgs) <= self.max_messages:
return ctx
# 最旧的 (总数 - 上限) 条送去压缩
cut = len(msgs) - self.max_messages
to_compress, keep = msgs[:cut], msgs[cut:]
summary = self.summarizer(to_compress)
ctx.messages = [{"role": "system", "content": f"[历史摘要] {summary}"}] + keep
return ctx
def process_response(self, ctx):
return ctx # 压缩不可逆,响应方向不做事process_response 通常是空实现,这点要和 PII 区分开,别搞混它和"记忆(memory)"不是一回事:memory 是把历史存下来,压缩是把历史变小。两者常配合,但职责不同。
有些动作不能让模型自己拍板:发邮件、转账、删库、对外发消息。这时候需要 HITL(Human-In-The-Loop,人工介入):在管道或状态机里暂停,把决策权交给人,人确认后再继续。

LangGraph 的 interrupt() 是这个模式的标杆实现:它不是在代码里写个 input() 卡住线程,而是在状态机层面暂停图执行,把需要确认的内容交还给调用方,外部用 Command(resume=...) 恢复。这意味着暂停可以跨进程、跨网络、带超时、可审计。
from langgraph.graph import StateGraph, START, END
from langgraph.types import interrupt, Command
def approval_node(state):
# interrupt 暂停图,把待确认信息交还调用方
decision = interrupt({
"action": state["pending_action"],
"payload": state["payload"],
})
return {"status": "approved" if decision["approve"] else "rejected"}
builder = StateGraph(dict)
builder.add_node("approval", approval_node)
builder.add_edge(START, "approval")
builder.add_edge("approval", END)
graph = builder.compile()
# 第一次调用:图在这里暂停,返回需要人工确认的内容
result = graph.invoke({"pending_action": "transfer", "payload": {"amount": 5000}})
# 人工在后台确认后,用 Command 恢复
result = graph.invoke(Command(resume={"approve": True}))process_request 置 blocked_pending_human 也行,但可恢复性和持久化远不如 interrupt中间件最容易被低估的一点:顺序即语义。它们叠成一条栈,前一个的输出是后一个的输入,换个顺序行为完全不同。

最经典的坑是日志中间件和 PII 中间件的位置关系。如果日志在脱敏之前运行,明文 PII 就写进了日志——合规直接爆雷。正确顺序是脱敏先行,日志后行,日志里只有占位符。
反例与反模式,这些是我在 code review 里打回最多的写法:
if '1' in text: hide(),脱敏、压缩、校验各写各的,顺序藏在调用时序里,新人根本看不到全貌,一改就串input() 或同步等待,没法在异步服务、分布式部署里恢复,重启即丢状态,也无法超时和审计回到本质:中间件的价值不是"多一层包装",而是把横切关注点变成可测试、可组合、顺序明确的单元。当脱敏是一个中间件、压缩是一个中间件、人工确认是一个中间件,你就能像搭积木一样组合它们,每个都能单独写单测,组合起来行为可预测。这才是 AI 工程化该有的样子。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。