Deep Agents 10. Middleware、动态工具与 Profiles


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 在一次用户请求中也可能重复运行。

Deep Agents 中间件栈

这也决定了两个工程原则:

  1. 不在 Middleware 实例字段中保存当前用户、当前角色等请求级可变数据;
  2. 对 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 的主要装配顺序为:

  1. Todo;
  2. 可选 Skills;
  3. Filesystem;
  4. 可选同步 SubAgent;
  5. Summarization;
  6. Patch Tool Calls;
  7. 可选 Async SubAgent;
  8. 用户 middleware=;
  9. Harness Profile 的 extra_middleware;
  10. Harness Profile Tool Exclusion;
  11. Provider Prompt Cache Middleware;
  12. 可选 Memory;
  13. 可选 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

单个 Agent 的运行时路由与双重授权

核心路由器只过滤应用自己的业务 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 模式一:筛选预注册工具

适合角色白名单、租户套餐和场景裁剪:

  1. 创建 Agent 时通过 tools= 注册完整业务集合;
  2. wrap_model_call 从 request.tools 中筛选本次可见子集;
  3. ToolNode 已经拥有这些工具的执行处理器;
  4. 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 配置中拆出来。

ProviderProfile 与 HarnessProfile 解析路径

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:初始模型 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,选择有三种:

  1. 构建两个 Agent Graph,由服务层路由;
  2. 在同一个 Middleware 中显式同步修改模型、Prompt 与 Tool;
  3. 把差异收敛为两个模型都兼容的公共 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,但执行节点说工具不存在

检查:

  1. 是预注册筛选还是运行时注册;
  2. 预注册模式是否把 Tool 放入 Agent tools=;
  3. 运行时模式是否同时实现 wrap_model_call 和 wrap_tool_call;
  4. Tool 名是否重复或不一致;
  5. 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 中的约定,而是可以测试、升级和审计的工程契约。


文章作者: hnbian
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 hnbian !
评论
  目录