1. Middleware 解决的不是一个问题
当 Agent 只有一个模型和两三个 Tool 时,把逻辑全部写在创建函数中也能运行。进入真实业务后,模型调用周围会迅速出现一批横切需求:
- 根据身份和租户过滤 Tool;
- 根据任务复杂度、时延目标和预算选择模型;
- 在模型调用前补充运行时上下文;
- 记录模型、路由理由、Token、延迟和 Tool Call;
- 对瞬时错误进行有上限的重试或 Fallback;
- 在数据进入模型前脱敏;
- 为某个 Provider 或模型族调整 Prompt 和 Tool 描述。
如果每个 Tool、每个 Agent 节点都复制这些代码,业务逻辑很快会与鉴权、观测和重试纠缠在一起。Middleware 的价值,就是把这类逻辑放到统一的执行切面中。
不过 Middleware 不是权限系统,也不是模型配置注册表。本文先给出三个边界:
| 能力 | 主要职责 | 不应该单独承担 |
|---|---|---|
| Middleware | 根据本次调用改变请求、响应或控制流 | 最终业务授权 |
| Tool/后端服务 | 校验身份、租户和业务权限 | 决定模型调优 Prompt |
| Profiles | 描述 Provider 初始化和模型 Harness 兼容性 | 每次请求的动态身份路由 |
一个完整实现通常会同时使用三层:Middleware 缩小模型可见面,Tool 再次鉴权,Profile 处理模型的稳定兼容配置。
1.1 本文的版本边界
本文示例固定在下面的实际环境中:
Python 3.12.11
deepagents==0.6.12
langchain-core==1.4.9
langgraph==1.2.9
Profiles 在该版本仍属于 Beta API。Middleware 栈顺序、Profile 字段和工具规范化时机都可能在升级后改变。因此本文不仅解释概念,还用 Recording Model 检查最终 Prompt、最终 Tool Schema 和实际 Graph 行为。
1.2 Middleware 是每次调用的运行时切面
Middleware 不是“Agent 启动时调用一次”的配置回调。Agent Loop 可能多次调用模型和 Tool,同一个 Hook 在一次用户请求中也可能重复运行。

这也决定了两个工程原则:
- 不在 Middleware 实例字段中保存当前用户、当前角色等请求级可变数据;
- 对 Hook 的执行次数和异常路径做明确设计,不能凭名称猜测生命周期。
2. Hook、调用次数与装配顺序
2.1 Node-style 与 Wrap-style
LangChain Agent Middleware 的 Hook 可以分成两类:
| 风格 | Hook | 典型用途 |
|---|---|---|
| Node-style | before_agent | 初始化 State、调用级校验 |
| Node-style | before_model | 模型调用前更新 State |
| Node-style | after_model | 模型响应后检查结果 |
| Node-style | after_agent | 正常完成后的汇总 |
| Wrap-style | wrap_model_call | 动态模型、动态 Tool、缓存、重试、Fallback |
| Wrap-style | wrap_tool_call | 参数检查、监控、执行器替换、错误转换 |
Node-style Hook 返回 State 增量,适合“到达这个节点时做什么”;Wrap-style Hook 收到 handler,可以选择调用零次、一次或多次,适合“包围并控制底层调用”。
每个同步 Hook 都有异步版本,例如 abefore_model、awrap_model_call。同步调用路径不能只实现异步 Hook,异步 Hook 中也不应直接执行阻塞网络操作。
2.2 一次 Agent 调用为什么会多次进入 Hook
一次包含 Tool Call 的典型执行序列是:
before_agent
before_model -> model -> after_model
wrap_tool_call -> tool
before_model -> model -> after_model
after_agent
因此:
- before_model、after_model 会随 Agent Loop 重复;
- 每一个 Tool Call 都会进入 Tool Wrapper;
- 并行 Tool Call 可能同时执行 Wrapper;
- after_agent 适合正常完成后的汇总,但异常、中断或进程退出时不能把它当成必达的 finally。
必须完成的资源释放应放在拥有资源的 try/finally、上下文管理器或更外层服务生命周期中,而不是只依赖 after_agent。
2.3 Wrap Middleware 是嵌套调用
当用户传入:
middleware=[outer, inner]
执行顺序不是先完整运行 outer,再完整运行 inner,而是:
outer:before
inner:before
model
inner:after
outer:after
这会直接影响重试、缓存和计时。计时器放在重试层外侧时记录总耗时,放在重试层内侧时记录单次尝试耗时。本文通过真实 Graph 断言了这一嵌套顺序。
2.4 ModelRequest 与不可变替换
wrap_model_call 收到的 ModelRequest 主要包含:
| 字段 | 含义 |
|---|---|
| model | 本次准备调用的模型 |
| messages | 当前对话消息 |
| system_message | 已组装的系统消息 |
| tools | 本次模型可见的 Tool |
| tool_choice | Tool 选择策略 |
| response_format | 结构化输出策略 |
| state | 当前 Agent State |
| runtime | Runtime Context、Store、Stream Writer 等,也可能是 None |
| model_settings | 本次模型调用参数 |
修改请求时使用 request.override(…):
updated = request.override(model=selected_model, tools=visible_tools)
return handler(updated)
它返回一个新 Request,不就地污染原始对象。本文也有直接断言原 Request 未改变的测试。
2.5 deepagents==0.6.12 的主 Agent 栈
对锁定版本的安装源码进行检查后,主 Agent 的主要装配顺序为:
- Todo;
- 可选 Skills;
- Filesystem;
- 可选同步 SubAgent;
- Summarization;
- Patch Tool Calls;
- 可选 Async SubAgent;
- 用户 middleware=;
- Harness Profile 的 extra_middleware;
- Harness Profile Tool Exclusion;
- Provider Prompt Cache Middleware;
- 可选 Memory;
- 可选 Human-in-the-loop。
这是版本实现,不是长期协议。它最重要的现实影响是:用户 Middleware 即使重新加入某个工具,后面的 Profile Exclusion 仍能把它移除。本文没有只根据源码推测,而是用直接契约测试验证了这个结果。
3. 可信上下文、动态模型与工具过滤
3.1 State、Runtime Context 和实例字段
三个位置的生命周期不同:
| 数据位置 | 生命周期 | 适合保存 |
|---|---|---|
| State | 当前线程,可 Checkpoint | messages、Todo、可恢复统计 |
| Runtime Context | 当前一次调用,只读 | tenant_id、role、模型档位 |
| Middleware 实例字段 | 整个编译图共享 | 只读配置、连接池、线程安全客户端 |
身份和模型路由信息来自认证层与服务端策略,不能从用户消息中的“我是管理员”推导。示例使用不可变 Dataclass:
@dataclass(frozen=True)
class AgentContext:
"""Immutable identity and routing data supplied by the trusted application."""
role: Role = "guest"
model_tier: ModelTier = "fast"
3.2 工具可见面采用 Fail Closed
示例注册三个业务 Tool,并定义角色白名单:
BUSINESS_TOOLS = [public_search, internal_lookup, export_customer_report]
BUSINESS_TOOL_NAMES = frozenset(tool.name for tool in BUSINESS_TOOLS)
ROLE_TOOL_NAMES: Mapping[Role, frozenset[str]] = {
"guest": frozenset({"public_search"}),
"analyst": frozenset({"public_search", "internal_lookup"}),
"admin": BUSINESS_TOOL_NAMES,
}
未知角色回退到 guest。ModelRequest.runtime 在类型上允许为 None,因此路由器也必须处理缺失 Runtime 的情况,而不能直接访问 request.runtime.context:
runtime = request.runtime
context = runtime.context if runtime is not None else None
role = context.role if isinstance(context, AgentContext) else "guest"
tier = context.model_tier if isinstance(context, AgentContext) else "fast"
缺失身份时收紧权限,并另行记录异常指标,比抛出一个空指针错误或默认管理员更安全。
3.3 同一个 Graph 按请求选择模型和 Tool

核心路由器只过滤应用自己的业务 Tool,不误删 write_todos、文件和 task 等 Harness 能力:
def build_request_router(models: Mapping[ModelTier, BaseChatModel]):
model_pool = dict(models)
if "fast" not in model_pool or "reasoning" not in model_pool:
raise ValueError("models must contain both 'fast' and 'reasoning'")
@wrap_model_call
def route_request(request, handler):
runtime = request.runtime
context = runtime.context if runtime is not None else None
role = context.role if isinstance(context, AgentContext) else "guest"
tier = context.model_tier if isinstance(context, AgentContext) else "fast"
allowed = visible_business_tools(role)
tools = [
candidate
for candidate in request.tools
if candidate.name not in BUSINESS_TOOL_NAMES
or candidate.name in allowed
]
model = model_pool.get(tier, model_pool["fast"])
return handler(request.override(model=model, tools=tools))
return route_request
模型池在应用启动时构造,不在 Hook 中根据用户输入临时创建 Provider Client。这样可以复用连接池、提前暴露配置错误,并避免任意模型名或 Base URL 变成 SSRF 与成本攻击入口。
3.4 依赖注入不能用真值判断
下面的写法看似简洁:
fast = fast_model or build_model()
但一个合法对象如果定义为 falsy,就会被意外替换。正确写法是明确判断 None:
fast = fast_model if fast_model is not None else build_model()
reasoning = reasoning_model if reasoning_model is not None else fast
本文专门注入了一个 bool 返回 False 的合法 Recording Model,确认它仍被使用。
3.5 可见性不是授权
受保护 Tool 还要读取同一份可信 Context:
@tool
def internal_lookup(
customer_id: str,
runtime: ToolRuntime[AgentContext],
) -> str:
"""Look up a customer after enforcing an analyst-or-admin role."""
_require_role(runtime, {"analyst", "admin"})
return f"internal:{customer_id}"
ToolRuntime 由执行框架注入,不出现在模型可填写的 Tool Schema 中。测试覆盖了两条路径:直接调用 Tool 时 guest 得到 PermissionError;通过完整 Agent Graph 强制发起未授权 Tool Call 时,当前默认配置同样传播 PermissionError。
如果应用配置了 Tool 错误转换 Middleware,完整 Graph 也可能把异常转换成 ToolMessage。无论采用哪一种用户体验,安全审计都不能依赖自然语言错误消息,后端仍需独立记录拒绝事件。
3.6 路由理由进入 Trace,而不是 Prompt
模型选择不能只看消息数量。更可靠的信号包括复杂 Tool Planning、多模态输入、结构化 Schema、上一次可恢复失败、用户预算和数据地域。
路由器应该产生类似 route_reason=complex_schema 的结构化 Trace 属性。不要为了“可观测”把内部策略、租户套餐或风险标签拼进 System Prompt;那既增加 Token,也可能向模型泄露不必要的控制信息。
4. 动态工具有两种完整实现
“动态工具”并不只等于从预注册集合里筛选。官方 Middleware 机制支持两种不同模式。

4.1 模式一:筛选预注册工具
适合角色白名单、租户套餐和场景裁剪:
- 创建 Agent 时通过 tools= 注册完整业务集合;
- wrap_model_call 从 request.tools 中筛选本次可见子集;
- ToolNode 已经拥有这些工具的执行处理器;
- Tool 内部继续鉴权。
本文第 3 节的 BUSINESS_TOOLS 就是这种模式。它的优点是简单、稳定、易于启动时审计。
4.2 模式二:运行时注册 Schema 与执行器
如果某个工具确实不能静态注册,Middleware 必须同时完成两件事:
- wrap_model_call:把工具 Schema 加入本次模型请求;
- wrap_tool_call:在执行阶段把同名 Tool Call 映射到真实处理器。
可执行示例为:
@tool
def calculate_tip(amount: float, percentage: float = 15.0) -> str:
"""Calculate a tip from a bill amount and percentage."""
return f"{amount * percentage / 100:.2f}"
class RuntimeToolRegistryMiddleware(AgentMiddleware):
"""Make calculate_tip visible and executable without static registration."""
def wrap_model_call(self, request, handler):
tools = [*request.tools, calculate_tip]
return handler(request.override(tools=tools))
def wrap_tool_call(self, request, handler):
if request.tool_call["name"] == calculate_tip.name:
return handler(request.override(tool=calculate_tip))
return handler(request)
测试用 Scripted Tool Model 真实生成 calculate_tip Tool Call,ToolNode 返回 3.00,随后模型完成回答。这证明不是只把名称塞进 Schema,而是完成了执行闭环。
4.3 只有 Schema 为什么会失败
如果只在 wrap_model_call 中加入一个从未注册的 Tool,模型能够生成合法 Tool Call,但执行阶段找不到对应处理器。这个故障通常表现为“模型明明调用了工具,ToolNode 却报告无效工具”。
判断标准很简单:
| 场景 | 模型侧 Schema | 执行侧处理器 |
|---|---|---|
| 预注册筛选 | 创建 Agent 时注册 | 创建 Agent 时注册 |
| 运行时注册 | wrap_model_call 注入 | wrap_tool_call 注入 |
| 错误实现 | wrap_model_call 注入 | 缺失 |
4.4 动态发现比运行时注册更复杂
从 MCP、插件目录或数据库动态发现 Tool,还要解决:
- Schema 校验与签名验证;
- Tool 名称冲突;
- 来源信任和租户隔离;
- 版本、缓存和撤销;
- 执行器连接生命周期;
- 超时、权限和返回值大小限制。
启动时应拒绝重复 Tool 名,避免模型 Schema 与执行路由指向不同实现。示例包含显式校验:
def validate_unique_tool_names(tools: list[object]) -> None:
names = [str(getattr(candidate, "name", "")) for candidate in tools]
duplicates = sorted({name for name in names if name and names.count(name) > 1})
if duplicates:
raise ValueError(f"duplicate tool names: {', '.join(duplicates)}")
4.5 Skills 与动态 Tool 不是同一个抽象
Agent Skills 主要通过 SKILL.md、references、assets 和 scripts 渐进披露操作知识。Skill 文件中出现一个 Python 函数名,不会自动把它注册成 Tool。
| 需求 | 首选能力 |
|---|---|
| 一套可复用操作手册和脚本 | Agent Skills |
| 同一 Agent 按角色缩小业务 API | 预注册 Tool 过滤 |
| 按请求接入临时执行器 | 运行时双 Hook 注册 |
| 专业角色需要独立上下文和模型 | SubAgent |
| 敏感动作的最终安全边界 | Tool 后端授权 + 必要时 HITL |
5. Prompt、错误、State 与并发安全
5.1 修改 SystemMessage 时保留元数据
ModelRequest.system_message 不是普通字符串。它可能含有多模态内容块、cache_control、id、name、additional_kwargs 和 response_metadata。
仅仅执行 SystemMessage(content=new_content) 会构造一个新对象,可能丢失原消息上的元数据。示例使用 model_copy,只替换 content:
current = request.system_message
if current is None:
updated = SystemMessage(content=scope_text)
else:
updated = current.model_copy(
update={
"content": [
*current.content_blocks,
{"type": "text", "text": scope_text},
]
}
)
return handler(request.override(system_message=updated))
测试为原 SystemMessage 设置了 id、name 和两类 metadata,确认追加内容后仍全部保留。动态内容应尽量放在稳定缓存前缀之后,减少 Prompt Cache 失效。
5.2 Tool 错误要按语义处理
| 错误类型 | 建议处理 |
|---|---|
| 客户 ID 不存在 | 返回紧凑、可修正的 ToolMessage |
| 当前角色无权限 | 拒绝、记录安全事件,不让模型猜测绕过 |
| 第三方临时 503 | 有上限重试或 Fallback |
| Tool 代码缺陷 | 抛出并告警,不伪装为空结果 |
| 返回数据过大 | 分页、聚合或写入 Backend 文件 |
不要使用 except Exception: return “failed” 吞掉所有异常。它会让 Agent、用户和 Trace 都无法区分业务失败、权限拒绝与程序缺陷。
Wrap Hook 可以调用 handler 零次、一次或多次,但重试逻辑必须限定可重试异常、次数、退避与总时限。本文不提供一个脱离具体 Provider 异常类型的“万能重试函数”,因为那段代码很容易把认证失败、Schema 错误甚至不可逆 Tool 副作用一起重试。
5.3 State 更新必须经过框架语义
Node-style Hook 返回 State 增量;Wrap-style Hook 若需要更新 State,应使用当前版本支持的响应与 Command(update=…) 机制,让更新经过 Reducer。不要直接修改共享 State 字典并假设 Checkpoint、并发分支和恢复都会自动理解这个副作用。
设计 Reducer 时还要明确:计数是相加、列表是追加还是覆盖、外层重试失败时哪些更新应丢弃。
5.4 Middleware 实例不能保存当前请求
下面的实现存在并发污染:
class UnsafeMiddleware(AgentMiddleware):
def before_agent(self, state, runtime):
self.current_tenant = runtime.context.tenant_id
一个编译图会被多个 Thread、并行 Tool 和 SubAgent 复用。请求级数据只放 Hook 局部变量、Runtime Context 或 State。必须共享的指标对象应是线程安全、异步安全的 Metrics Client。
Harness Profile 的 extra_middleware 如果内部含可变状态,应使用零参数 Factory:
HarnessProfile(
extra_middleware=lambda: [RequestScopedCounterMiddleware()]
)
Factory 的语义是每次物化栈时创建新实例;它仍不意味着每次模型调用都新建 Middleware。具体实例边界要用测试确认。
6. ProviderProfile 与 HarnessProfile
Profiles 把某个 Provider 或模型需要的稳定兼容设置从业务 Agent 配置中拆出来。

6.1 三种同名概念
| 名称 | 所属层 | 作用 |
|---|---|---|
| LangChain Model Profile | 模型元数据 | 上下文窗口、Tool Calling、图片输入等能力 |
| Deep Agents ProviderProfile | 模型构造 | init_chat_model 默认参数与初始化前检查 |
| Deep Agents HarnessProfile | Agent Harness | Prompt、Tool、Middleware、默认 SubAgent |
文章 7 中用于摘要阈值的 model.profile[“max_input_tokens”] 是模型能力元数据,不是本节的 ProviderProfile。
6.2 ProviderProfile 只作用于模型字符串
模型字符串的解析过程是:
provider:model
-> 合并 ProviderProfile
-> init_chat_model
-> BaseChatModel
-> 解析 HarnessProfile
-> 创建 Agent Harness
ProviderProfile 的主要字段包括:
| 字段 | 作用 |
|---|---|
| init_kwargs | 静态默认参数 |
| init_kwargs_factory | 解析时从环境生成参数 |
| pre_init | 模型构造前的检查或初始化副作用 |
预构造 BaseChatModel 实例不会重新应用 ProviderProfile。pre_init 是按注册合并顺序执行的 Hook,不应把它描述成普通字典的“参数优先级”。
Provider 配置缺失时应在创建无效 Client 前失败:
def teaching_provider_kwargs() -> dict[str, object]:
base_url = os.getenv("TEACHING_BASE_URL")
if not base_url:
raise RuntimeError("TEACHING_BASE_URL is required")
return {"base_url": base_url}
示例的静态参数、模型级参数、Factory 结果和调用者参数合并后为:
{'temperature': 0.2, 'timeout': 45,
'base_url': 'https://provider.example.invalid/v1'}
其中调用者的 timeout=45 覆盖默认 30,模型级温度覆盖 Provider 级温度。示例 URL 明确使用保留的 .invalid 域名,不会误连真实服务。
6.3 HarnessProfile 调整 Agent Harness
deepagents==0.6.12 的 HarnessProfile 主要字段为:
| 字段 | 作用 |
|---|---|
| base_system_prompt | 替换 SDK 基础 Prompt |
| system_prompt_suffix | 在组装结果末尾追加模型调优指令 |
| tool_description_overrides | 按名称覆盖 Tool 描述 |
| excluded_tools | 从最终模型可见工具中排除 Tool |
| excluded_middleware | 删除允许移除的 Middleware |
| extra_middleware | 追加 Middleware 实例或 Factory |
| general_purpose_subagent | 调整或关闭默认通用 SubAgent |
示例注册:
register_harness_profile(
"demo:profile-recording",
HarnessProfile(
system_prompt_suffix="[PROFILE] 所有结论必须标注工具来源。",
tool_description_overrides={
"public_search": "[PROFILE] 只查询公开教学数据。"
},
excluded_tools=frozenset({"execute", "internal_lookup"}),
general_purpose_subagent=GeneralPurposeSubagentProfile(enabled=False),
),
)
execute 只有在 Backend/Sandbox 能力满足条件时才可能出现,并不是所有 Deep Agent 都固定拥有的工具。Profile 排除不存在的可选工具不会凭空创建或证明它存在。
6.4 Prompt 组装顺序要限定对象
对 0.6.12 主 Agent 的 Harness Prompt 组装,顺序为:
调用者 system_prompt
-> SDK BASE 或 Profile base_system_prompt
-> Profile system_prompt_suffix
这一定义描述的是 Harness 在建图时的 Prompt 组装,不代表后续所有 Middleware 永远不能再修改 SystemMessage,也不代表 SubAgent 的每一种专用 Prompt 都完全相同。默认通用 SubAgent 若有专用 system_prompt,它会覆盖 Profile 的 base,仅继续叠加 suffix。
base_system_prompt 的影响远大于 suffix。除非能验证 Todo、文件和 SubAgent 指令仍然完整,否则优先使用短 suffix。
6.5 Tool 描述覆盖存在规范化边界
在锁定版本中,Profile 对 @tool/BaseTool 的描述覆盖已通过真实最终请求验证。普通 Python callable 会在另一个阶段被规范化;本文的直接测试显示,它保留了原 docstring,没有应用同名 Profile 覆盖。
因此,不应笼统承诺“所有 callable 都能覆盖”。需要稳定覆盖时先显式使用 @tool 或构造 BaseTool,并检查最终 bind_tools 收到的 description。
覆盖 task 描述时还要保留 {available_agents} 占位符。拼错 Tool 名通常不会得到预期覆盖,所以升级测试应检查最终 Schema,而不是只检查 Profile Dataclass。
6.6 排除表示兼容性可见面,不表示授权
excluded_tools 是模型 Harness 的可见性/兼容性控制。例如某模型不适合使用某个执行工具,Profile 可以全局隐藏它。但它不是租户权限系统:
- 角色权限仍由 Runtime Context 和 Tool 后端判断;
- Profile Key 通常按 Provider/模型匹配,不按当前用户匹配;
- 隐藏 Tool 不会撤销后端 API 凭证。
excluded_middleware 与 excluded_tools 也不能混用。0.6.12 会拒绝移除必需脚手架,例如 FilesystemMiddleware 和 SubAgentMiddleware。
6.7 注册、合并与配置安全
Profile 注册表是进程级、累加合并的,应在应用启动阶段集中注册,完成后再创建 Agent。不要在每次请求中注册。
| 字段 | 合并方式 |
|---|---|
| Prompt 标量 | 新的非空值覆盖 |
| Tool 描述字典 | 按 Key 合并 |
| 排除集合 | 并集,只会更严格 |
| extra_middleware | 按具体 Class 合并 |
| Provider init_kwargs | 按 Key 合并 |
| Provider pre_init | 顺序组合执行 |
| Provider Factory | 依次执行,后者覆盖同名 Key |
HarnessProfileConfig 适合 YAML/JSON 友好的字段。若配置机制允许 Python 模块引用或导入引用,加载配置本质上可能执行受信任 Python 代码;不能把租户上传的任意配置当成普通数据直接导入。配置文件、模块路径和插件入口都必须进入部署供应链审计。
7. 动态模型与 Profile 的组合契约
7.1 Profile 在建图时解析
create_deep_agent 先解析初始模型,再选择 HarnessProfile 并组装 Prompt、Tool 覆盖、extra Middleware 和排除项。运行时的 wrap_model_call 只替换当前 request.model,不会重新编译整个 Graph。

本文建立两个不同 Profile:初始模型 Profile 带 [INITIAL PROFILE] 并排除 internal_lookup,运行时模型 Profile 带 [RUNTIME PROFILE]。用户 Middleware 切换到第二个模型并尝试重新加入 internal_lookup,最终 Recording Model 观察到:
- 真正执行的是运行时模型;
- Prompt 中仍是 [INITIAL PROFILE];
- [RUNTIME PROFILE] 没有自动出现;
- internal_lookup 仍被后置的 Profile Exclusion 移除。
这同时验证了“Profile 在建图时解析”和“用户 Middleware 位于 Profile Tool Exclusion 之前”两条契约。
如果两个模型需要完全不同的 Harness,选择有三种:
- 构建两个 Agent Graph,由服务层路由;
- 在同一个 Middleware 中显式同步修改模型、Prompt 与 Tool;
- 把差异收敛为两个模型都兼容的公共 Harness。
仅执行 request.override(model=…),不能假设 Profile 自动跟着切换。
7.2 确定性运行结果
运行命令:
cd source/_posts/deepagent/examples
TEACHING_BASE_URL=https://provider.example.invalid/v1 \
.venv_deepagent/bin/python -m p10_middleware_profiles.run_demo
实际输出:
guest_model=fast
guest_business_tools=['public_search']
analyst_model=reasoning
analyst_business_tools=['internal_lookup', 'public_search']
profile_suffix_applied=True
profile_all_visible_tools=['edit_file', 'export_customer_report', 'glob', 'grep', 'ls', 'public_search', 'read_file', 'write_file', 'write_todos']
profile_visible_business_tools=['export_customer_report', 'public_search']
provider_kwargs={'temperature': 0.2, 'timeout': 45, 'base_url': 'https://provider.example.invalid/v1'}
这里同时输出“全部可见工具”和“可见业务工具”。
7.3 20 个测试项覆盖什么
当前 P10 共 20 个 pytest item,覆盖:
- 未知角色和缺失 Runtime 按 guest Fail Closed;
- 模型池完整性与 falsy 依赖注入;
- guest/analyst 的模型和业务 Tool 集;
- 直接 Tool 与完整 Agent 路径的权限拒绝;
- Wrap 嵌套顺序;
- Harness Profile 的 Prompt、描述、Tool 和 SubAgent 效果;
- Provider 参数合并和缺失 Base URL 提前失败;
- HarnessProfileConfig 往返;
- 必需脚手架不可排除;
- Request override 不修改原对象;
- SystemMessage 元数据保留;
- 重复 Tool 名启动失败;
- 运行时双 Hook Tool 真正执行;
- 动态模型不会重解析 Profile;
- 用户 Middleware 后仍执行 Profile Tool Exclusion;
- 普通 callable 描述覆盖的版本边界。
运行:
cd source/_posts/deepagent/examples
.venv_deepagent/bin/ruff check p10_middleware_profiles
.venv_deepagent/bin/python -m compileall -q p10_middleware_profiles
.venv_deepagent/bin/pytest p10_middleware_profiles/test_profile.py
结果:
All checks passed!
.................... [100%]
20 passed
7.4 在线模型结果应怎样解读
仓库已有一次真实网关在线验收记录:
{
"scene": "p10_dynamic_model_and_tool_routing",
"status": "passed",
"latency_ms": 13575,
"routes": [
{"role": "guest", "model": "qwen3.6-flash", "tool": "public_search"},
{"role": "analyst", "model": "qwen3.7-plus", "tool": "internal_lookup"}
],
"protected_tool_not_exposed": "export_customer_report"
}
这份结果能证明两个请求选中了不同网关模型 ID 和目标 Tool,但结果结构没有保存每条路由的独立耗时、Guest ToolMessage 和 Token Usage,不能据此补写不存在的数据。
当前 live_demo.py 已改为对 Guest 与 Analyst 分别记录:模型 ID、延迟、Tool Call、Tool Result 和 Usage;下一次具备有效在线凭据时会输出更完整的脱敏 JSON。qwen3.6-flash、qwen3.7-plus 在这里是第三方 OpenAI 兼容网关返回的模型别名,不等同于对原厂版本、底层权重或 SLA 的证明。
确定性测试负责稳定契约,在线测试负责 Provider 兼容性与真实 Tool Calling;不能用随机自然语言输出替代前者。
8. 排错、生产检查与总结
8.1 常见故障
模型生成了 Tool Call,但执行节点说工具不存在
检查:
- 是预注册筛选还是运行时注册;
- 预注册模式是否把 Tool 放入 Agent tools=;
- 运行时模式是否同时实现 wrap_model_call 和 wrap_tool_call;
- Tool 名是否重复或不一致;
- Profile 是否在后置阶段排除了 Tool。
所有用户都只看到 guest Tool
检查 context_schema、调用时的 context=、认证层到 Runtime Context 的映射,以及 Runtime 是否在当前入口中确实存在。代码应继续 Fail Closed,但同时发出可观测告警。
Profile 没有生效
检查注册是否发生在建图之前、Key 是 Provider 级还是 provider:model、传入的是字符串还是预构造实例、自定义模型是否暴露 Provider/模型标识,以及是否错误期待运行时模型切换会重解析 Profile。
并发请求偶尔串角色或 Prompt
检查 Middleware 实例字段、全局可变 Tool 列表、原地修改 Request、请求期间动态注册 Profile、缓存 Key 是否包含 tenant/thread。优先使用不可变 Context、局部变量和 request.override。
8.2 生产检查表
- 每个 Hook 的执行次数和异常路径已经明确;
- Wrap 顺序有自动化测试;
- Runtime Context 来自可信认证层;
- 缺失 Runtime、未知角色和非法模型档位 Fail Closed;
- 模型池在启动时创建,Provider 与 Base URL 使用白名单;
- 预注册筛选和运行时注册没有混淆;
- Tool 名唯一,Schema 与执行处理器成对存在;
- Tool 后端执行真正的租户与业务授权;
- 路由理由进入结构化 Trace,不泄露到 Prompt;
- 修改 SystemMessage 时保留内容块和元数据;
- Middleware 实例不保存请求级可变状态;
- 重试限定错误类型、次数、退避和总时限;
- Profile 在启动阶段集中注册;
- ProviderProfile 与 HarnessProfile 职责分离;
- 动态模型与建图时 Profile 的组合有直接契约测试;
- Profile 配置和 Python import reference 只来自可信部署供应链;
- Beta API 升级前重新检查源码、最终 Prompt 和最终 Tool Schema。
8.3 示例文件
source/_posts/deepagent/examples/p10_middleware_profiles/
├── dynamic_agent.py # Runtime Context、模型路由、Tool 过滤和鉴权
├── runtime_tools.py # 运行时双 Hook Tool 注册
├── recording_model.py # Recording Model 与 Scripted Tool Model
├── profile_demo.py # Provider/Harness Profile 真实装配
├── run_demo.py # 确定性运行输出
├── live_demo.py # 在线模型脱敏验收入口
├── live_result.json # 已保存的在线结果
└── test_profile.py # 20 个契约测试
8.4 总结
Middleware 让同一个 Agent Graph 能按请求改变模型、Prompt、Tool 和控制流,但“模型看不到某工具”不等于“用户没有权限”。安全边界仍在 Tool 和后端服务。
动态工具有两条完整路径:从已注册集合中筛选,或者同时通过模型 Hook 和 Tool Hook 注册 Schema 与执行器。只完成模型侧 Schema 是一个无法执行的半成品。
Profiles 处理的是稳定的模型兼容配置:ProviderProfile 决定模型字符串怎样构造,HarnessProfile 决定建图时怎样调整 Prompt、Tool、Middleware 和默认 SubAgent。运行时切换模型不会自动重解析 HarnessProfile。
最终,可靠性来自分层和证据:用 Runtime Context 做可信路由,用 Tool 做最终授权,用 Profiles 管理模型差异,再用确定性模型观察最终请求、用在线模型验证真实 Provider 行为。这样,动态能力才不是隐藏在 Prompt 中的约定,而是可以测试、升级和审计的工程契约。