Deep Agents 14. Agent Server、React 前端与生产可观测性


前十三篇文章已经完成了 Deep Agent 的构建、工具接入、执行隔离、上下文工程、多智能体协作、人工审批和综合项目。但是,能够在 Python 进程里执行 agent.invoke(),与能够安全地交付给用户之间,还有一段不短的距离。

生产系统面对的不是一次函数调用,而是可能持续数分钟、跨越多个 Tool 和 SubAgent、等待人工审批、经历浏览器断线甚至服务重启的一次运行。我们必须回答下面这些问题:

  1. Agent 如何以稳定协议对外提供服务?
  2. 用户刷新页面以后,如何回到原来的任务?
  3. 审批发生在另一个 HTTP 请求中时,如何继续原来的执行点?
  4. 浏览器断开、停止接收事件和取消服务端任务有什么区别?
  5. 知道一个 thread_id,是否就能读取或恢复这个 Thread?
  6. 出现慢请求时,究竟是模型慢、Tool 慢,还是任务在队列中等待?

本篇将使用一个可重复的确定性模型启动本地 Agent Server,真实验证 REST、Python SDK、v2 Streaming、Checkpoint、HITL Resume、Run Cancel 和服务重启恢复。随后使用当前前端包 @langchain/react 实现 React 客户端,并补齐 Gateway、租户隔离、审批授权、输出脱敏和生产可观测性边界。

先说明一个容易引起误解的地方:示例中的 publish_report 是 HITL 协议演示 Tool。它不写数据库、不上传对象存储,也不调用外部发布 API。本文验证的是“审批前不执行 Tool,审批后可以从同一 Thread 恢复”,不是“真实业务发布已经完成”。

1. 服务化对象与生产架构

1.1 Graph、Assistant、Thread 与 Run

使用 Agent Server 时,首先要区分四个对象。它们不是同一个概念的不同叫法,而是四个不同的生命周期。

对象 含义 典型生命周期 本文中的例子
Graph Agent 的执行蓝图,包括节点、Tool、Middleware 和状态结构 随应用版本发布 deepagent
Assistant Graph 与一组配置组合后的可调用实例 可长期复用、版本化 默认 Assistant UUID
Thread 保存状态与 Checkpoint 的容器 跨多个 Run 存在 一次用户会话或业务任务
Run Assistant 在某个 Thread 上的一次执行 排队、运行、中断、完成或取消 “检查服务”“发布报告”

Graph、Assistant、Thread 与 Run 的关系

一个 Thread 可以承载多个 Run。例如,第一个 Run 接收“发布报告”,执行到 publish_report 前产生 Interrupt;审核人稍后提交审批,服务端在同一个 Thread 上创建另一个 Run,并从 Checkpoint 继续执行。Run 是一次执行,Thread 才是连续状态的载体。

官方的 Threads 文档也把 Thread 定义为一组 Run 的持久状态容器。需要注意,thread_id 是资源标识符,不是访问凭据。攻击者即使猜到一个有效 ID,也不能因此获得读取、取消或恢复权限。

1.2 生产请求不能从浏览器直达内部 Agent Server

本地学习时可以让 SDK 连接 http://127.0.0.1:2024,生产环境不应照搬。浏览器既不应该持有 Agent Server 的 Service Key,也不应该自己声明 tenant_id 和 user_id。

Deep Agents 生产服务架构

一条可信的请求链路应当是:

  1. 浏览器携带站点登录 Cookie 请求同源 Gateway/BFF;
  2. Gateway 验证登录态、CSRF、租户、角色和资源所有权;
  3. Gateway 根据认证主体生成可信 Runtime Context;
  4. Gateway 使用服务端凭据访问 Agent Server;
  5. Agent Server 管理 Assistant、Thread、Run、队列和 Checkpoint;
  6. Worker 执行 Graph、模型、Tool、MCP、Sandbox 和 SubAgent;
  7. Trace、指标和结构化日志进入观测系统。

API 服务与执行 Worker 的扩缩容依据不同:API 服务受到连接数、SSE 长连接和状态查询影响;Worker 受到排队任务、模型并发、Tool 时长和 Sandbox 容量影响。将两者视为一个普通 Web 进程,会掩盖真正的容量瓶颈。

1.3 本文锁定的环境

本文不是描述一个不受版本约束的抽象 API。所有字段和运行结果都基于下列锁定版本:

组件 验证版本
Python 3.12.11
deepagents 0.6.12
langgraph-cli 0.4.31
langgraph-api 0.10.3
Python langgraph-sdk 0.4.2
@langchain/react 1.0.28
TypeScript @langchain/langgraph-sdk 1.9.27
@langchain/core 1.1.48
React 19.2.7
TypeScript 5.9.3
Vite 7.3.6

Python SDK 和 TypeScript SDK 的版本号没有对应关系,不能因为名称相似就强行对齐。Streaming 与前端 Hook 仍在快速演进,升级时必须重新执行本文的类型检查和协议测试。

2. 可部署 Graph 与确定性验证契约

2.1 项目结构

示例位于 source/_posts/deepagent/examples/p14_production_frontend:

p14_production_frontend/
├── agent.py                   # Graph、Tool 和两种模型模式
├── gateway_policy.py          # 可执行的授权策略契约
├── langgraph.json             # Agent Server 应用描述
├── pyproject.toml             # 独立部署依赖
├── restart_resume.py          # 跨进程重启恢复测试
├── test_server_graph.py       # 确定性单元测试
├── verify_agent_server.py     # REST、SDK、HITL 与 Cancel 验收
└── frontend/
    ├── package.json
    ├── package-lock.json
    └── src/
        ├── main.tsx
        └── styles.css

langgraph.json 从当前目录安装依赖并导出 graph:

{
  "dependencies": ["."],
  "graphs": {
    "deepagent": "./agent.py:graph"
  },
  "env": "../.env"
}

命令必须在 p14_production_frontend 目录执行,因此 ../.env 指向 examples/.env。Shell 中已经导出的同名变量可能覆盖文件值,排查模型配置时应同时检查启动进程的环境。真实密钥只放入 Git 忽略的 .env,不能写入文章、日志、前端构建产物或 Trace。

2.2 为什么服务协议测试不能完全依赖远程模型

远程模型测试很重要,但不适合作为唯一的服务回归测试。API Key 过期、限流、网络波动、模型升级后不再选择 Tool,都会让测试失败,却不能说明 Agent Server 协议本身出了问题。

因此示例保留 RuleBasedChatModel:

  • 输入“检查服务”时固定调用 health_check;
  • 输入“发布报告”时固定调用 publish_report;
  • 输入“执行长任务”时固定调用 slow_operation;
  • 收到 ToolMessage 后生成确定性最终回答;
  • bind_tools() 返回新的模型快照,不修改共享实例。

最后一点是为了避免并发请求之间互相覆盖 Tool 绑定。核心实现如下:

class RuleBasedChatModel(BaseChatModel):
    """Stateless Tool-Calling test double used for service verification."""

    model_name: str = "p14-rule-based"
    bound_tool_names: tuple[str, ...] = ()

    @property
    def _llm_type(self) -> str:
        return "p14-rule-based-chat-model"

    def bind_tools(self, tools: Any, **_: Any) -> RuleBasedChatModel:
        """Return an isolated snapshot so concurrent bindings cannot mutate self."""

        names = tuple(
            sorted(str(getattr(item, "name", type(item).__name__)) for item in tools)
        )
        return self.model_copy(update={"bound_tool_names": names})

确定性模型是 Test Double,不是 LLM 模拟结果。它验证 Tool Calling、Agent Server、Checkpoint、Streaming 和 HITL 的结构行为;P14_MODEL_MODE=live 则创建真实 ChatOpenAI 兼容模型。两类测试解决的问题不同,不能相互替代。

2.3 publish_report 的真实验证边界

Tool 的完整返回值故意带有 external_side_effect=false:

@tool
def publish_report(
    report_id: str,
    operation_id: str,
    target_environment: Literal["staging", "production"],
    runtime: ToolRuntime[TenantContext],
) -> str:
    """Simulate publication after HITL; this Tool has no external side effect."""

    context = runtime.context
    if not isinstance(context, dict):
        raise PermissionError("trusted runtime context is required")
    tenant_id = str(context.get("tenant_id", "")).strip()
    user_id = str(context.get("user_id", "")).strip()
    if not tenant_id or not user_id:
        raise PermissionError("tenant_id and user_id are required")
    clean_report_id = report_id.strip()
    clean_operation_id = operation_id.strip()
    if not REPORT_ID_PATTERN.fullmatch(clean_report_id):
        raise ValueError("report_id must match [A-Z0-9][A-Z0-9._-]{0,63}")
    if not OPERATION_ID_PATTERN.fullmatch(clean_operation_id):
        raise ValueError("operation_id contains unsupported characters")
    return (
        f"simulated_publish:tenant={tenant_id};report={clean_report_id};"
        f"operation={clean_operation_id};environment={target_environment};"
        f"approved_by={user_id};external_side_effect=false"
    )

这里验证了可信 Context、参数白名单、审批中断和 Resume,但没有验证真实发布的事务语义。若换成真实副作用,至少还要增加:

生产能力 需要补充的实现
幂等 使用 tenant_id + operation_id 唯一约束
审计 记录请求人、审核人、参数哈希和结果
未知结果 外部 API 超时后先查询操作状态,不能盲目重试
补偿 明确哪些动作可回滚,哪些只能追加反向操作
权限 Tool 内再次校验租户资源和目标环境

HITL Interrupt 不是数据库事务,也不会自动撤销 Interrupt 前已经发生的外部副作用。

2.4 构建 Graph

def build_agent(
    model: BaseChatModel,
    *,
    checkpointer: InMemorySaver | Literal[True] | None = None,
):
    return create_deep_agent(
        model=model,
        tools=[health_check, publish_report, slow_operation],
        system_prompt=(
            "检查服务时必须调用 health_check;发布报告时必须调用 publish_report,"
            "report_id 使用 RPT-2026-0713,operation_id 使用 publish-2026-0713-001,"
            "target_environment 使用 staging;长任务必须调用 slow_operation。"
            "publish_report 只是协议模拟,禁止声称外部业务系统已经改变。"
        ),
        context_schema=TenantContext,
        interrupt_on=PUBLISH_POLICY,
        checkpointer=checkpointer,
    )


graph = build_agent(model_from_environment(), checkpointer=True)

checkpointer=True 表示部署时由 Agent Server Runtime 注入 Checkpointer。进程内单元测试则显式传入 InMemorySaver()。不要在同一个导出 Graph 上再绑定只属于某个 Web Worker 的全局状态。

3. Agent Server 协议验证

3.1 启动本地服务

在 p14_production_frontend 目录执行:

../.venv_deepagent/bin/langgraph dev \
  --config langgraph.json \
  --host 127.0.0.1 \
  --port 2035 \
  --no-browser \
  --no-reload

启动日志会明确提示:这是用于开发和测试的 in-memory server,不是生产部署。本文不会把 langgraph dev 描述为生产架构,只用它验证协议与本地恢复行为。官方的 Local Agent Server 文档也把它定位为本地开发入口。

先检查原始 REST 接口:

curl -s http://127.0.0.1:2035/ok
{"ok": true}

/ok 只能说明 HTTP 服务可访问,不能说明 Graph 已导入、Tool 能执行或 Checkpoint 能恢复。因此还需要 SDK 验收。

3.2 SDK 验收覆盖什么

verify_agent_server.py 依次执行:

  1. 按 graph_id=deepagent 搜索唯一默认 Assistant;
  2. 创建健康检查 Thread;
  3. 使用 Python SDK 发起 v2 Stream;
  4. 记录 Run ID、首事件耗时、总耗时和事件类型;
  5. 读取当前 State 和有界 History;
  6. 创建审批 Thread,验证 publish_report Interrupt;
  7. 在相同 Thread 上 Approve 并 Resume;
  8. 创建 30 秒协作式长任务,调用 Run Cancel API。

关键调用如下:

async for part in client.runs.stream(
    health_thread_id,
    assistant_id,
    input={"messages": [{"role": "user", "content": "检查服务"}]},
    context=CONTEXT,
    stream_mode=["updates", "messages"],
    stream_resumable=True,
    on_disconnect="continue",
    on_run_created=lambda item: health_run_ids.append(str(item["run_id"])),
    version="v2",
):
    event_types.add(str(part["type"]))

这里的 stream_resumable 和 on_disconnect 是本文锁定的 Python SDK / Agent Server 调用参数。它们不能机械地搬到 React useStream;前端包有自己的生命周期 API,后文会单独说明。

3.3 本次真实运行结果

本次在本机启动 langgraph-api==0.10.3 后得到以下关键结果:

{
  "rest_ok": true,
  "assistant_graph": "deepagent",
  "stream_events": [
    "messages/complete",
    "messages/metadata",
    "metadata",
    "updates"
  ],
  "first_event_latency_ms": 989,
  "health_run_latency_ms": 1021,
  "health_result": "工具执行结果:ok",
  "interrupt_tool": "publish_report",
  "publish_tool_result": "simulated_publish:tenant=tenant-demo;report=RPT-2026-0713;operation=publish-2026-0713-001;environment=staging;approved_by=user-reviewer;external_side_effect=false",
  "cancel": {
    "status": "interrupted"
  }
}

这份结果能够说明:Graph 已加载、Assistant 可发现、v2 Stream 有实际事件、Tool 循环完成、Interrupt 可序列化、Resume 执行了协议模拟 Tool、Cancel API 改变了长任务状态。

它不能说明模型质量、生产数据库可靠性、跨区域容灾或真实业务副作用已经得到验证。首事件耗时也只是本次本机样本,不是性能基线。

3.4 不要把 History 数量写成固定协议

脚本使用 get_history(…, limit=20),只断言返回的状态数量大于 1,并记录当前 checkpoint_id。一次 Tool 循环究竟产生多少 Checkpoint,会受到 Graph 结构、中间件和 Runtime 版本影响;本次观察到 10 条,不应把“必须等于 10”写成稳定断言。

更可靠的断言是:

  • 当前 State 包含期望的最终 ToolMessage;
  • History 中存在多个状态演进点;
  • Interrupt 前 Tool 尚未产生成功结果;
  • Resume 后出现同一个操作的 Tool 结果;
  • 关键 Checkpoint ID 非空。

4. Thread、Run、Checkpoint 与 HITL

4.1 中断与恢复的状态机

HITL 与持久 Run 生命周期

当模型请求 publish_report 时,interrupt_on 使 Graph 在 Tool 执行前停下。此时服务端需要保存的不只是消息,还包括待执行 Tool、参数、Graph 位置和 Interrupt 信息。审核请求到来后,Resume 必须指向原 Thread 和正确 Interrupt。

paused = await client.runs.wait(
    approval_thread_id,
    assistant_id,
    input={"messages": [{"role": "user", "content": "发布报告"}]},
    context=CONTEXT,
)

resumed = await client.runs.wait(
    approval_thread_id,
    assistant_id,
    command={"resume": {"decisions": [{"type": "approve"}]}},
    context=CONTEXT,
)

Approve、Reject 和修改 Tool Call 是控制流决策,不是对外部系统的事务回滚。尤其是在并行 Tool、SubAgent 或自定义 Middleware 中,Interrupt 前可能已经完成了别的动作,因此真正有副作用的 Tool 仍然需要幂等键和审计记录。

4.2 跨 Agent Server 进程重启验证

只在同一进程中读取 State,不能证明重启恢复。本例增加两阶段脚本:

# 第一阶段:创建 Thread,在 publish_report 前暂停
../.venv_deepagent/bin/python restart_resume.py pause \
  --url http://127.0.0.1:2035 \
  --state-file /tmp/p14-restart-resume.json

# 完全停止 Agent Server,再从同一目录重新启动

# 第二阶段:用原 thread_id 恢复
../.venv_deepagent/bin/python restart_resume.py resume \
  --url http://127.0.0.1:2035 \
  --state-file /tmp/p14-restart-resume.json

第二阶段实际输出:

{
  "resume_result": "工具执行结果:simulated_publish:tenant=tenant-demo;report=RPT-2026-0713;operation=publish-2026-0713-001;environment=staging;approved_by=user-reviewer;external_side_effect=false",
  "tool_result": "simulated_publish:tenant=tenant-demo;report=RPT-2026-0713;operation=publish-2026-0713-001;environment=staging;approved_by=user-reviewer;external_side_effect=false"
}

这证明 本文版本、本文启动目录和本次正常关停条件下,本地开发 Runtime 将待审批状态刷新到本地存储,并能在新进程中恢复。它仍不能替代 PostgreSQL、跨 Worker、备份恢复、异常断电和多副本测试。官方 Persistence 文档说明了 Checkpointer、Thread 和 Checkpoint 的关系;具体部署后端仍要按目标平台验收。

4.3 Stop、Cancel 与 Disconnect

这三个动作经常被混为一谈:

Stop、Cancel 与 Disconnect 的区别

动作 浏览器连接 服务端 Run 当前 @langchain/react 调用
Disconnect 断开 默认继续 stream.disconnect()
Stop 停止当前客户端流,并默认请求服务端取消 默认取消 stream.stop()
Cancel API 与是否仍连接无关 服务端进入取消/中断终态 Python SDK runs.cancel(…)

本文锁定的 @langchain/react==1.0.28 中,stop() 默认执行服务端 Cancel;disconnect() 等价于 stop({cancel: false})。这是具体版本行为,不应写成所有前端 SDK 的永恒规则。官方 Cancel Runs 文档也把 Cancel 定义为独立的服务端操作。

取消还依赖任务是否可协作停止。示例 slow_operation 使用 await asyncio.sleep(),因此 Runtime 可以中断它;阻塞式 C 扩展、忽略取消信号的外部进程或已经发出的第三方请求,不会因为前端点击按钮就自动回滚。

4.4 同一 Thread 的并发策略

用户双击发送、多个浏览器标签页以及后台自动任务,都可能同时向一个 Thread 提交 Run。本文前端显式使用:

await stream.submit(
  { messages: [{ type: "human", content }] },
  { multitaskStrategy: "reject" },
);

选择 reject 是为了让并发冲突显式暴露,避免审批期间的新输入悄悄改变状态。其他策略的精确语义与默认值受 SDK 和 Agent Server 版本影响,升级前应阅读锁定版本类型定义并运行竞争测试,而不是只根据策略名称推断行为。

5. React 前端与 Streaming

5.1 从旧 React 入口迁移到 @langchain/react

前端现在从当前推荐包导入 Hook:

import type { BaseMessage } from "@langchain/core/messages";
import type { Message } from "@langchain/langgraph-sdk";
import { useStream } from "@langchain/react";

项目锁定:

{
  "dependencies": {
    "@langchain/core": "1.1.48",
    "@langchain/langgraph-sdk": "1.9.27",
    "@langchain/react": "1.0.28",
    "zod": "4.1.12"
  }
}

不再从旧的 @langchain/langgraph-sdk/react 导入,也不再用 as UseDeepAgentStream 强制掩盖类型不匹配。官方的 React HITL 前端文档同样使用 @langchain/react 的 useStream。

当前 Hook 没有旧示例中的 reconnectOnMount 与 streamResumable 配置。挂载时通过 threadId 恢复 Thread 状态;运行中的重新连接策略则应按当前 SDK 和服务端协议设计。不能把 Python SDK 的同名能力当成 React Hook 属性。

5.2 同源 Gateway 与隔离的 Thread 存储键

前端先请求同源 /api/session,由 Gateway 返回非敏感的会话引导信息:

const SessionSchema = z.object({
  environment: z.string().min(1).max(32),
  assistantId: z.string().min(1).max(128),
  sessionScope: z.string().regex(/^[a-zA-Z0-9._-]{1,128}$/),
  agentApiUrl: z.string().startsWith("/"),
  csrfToken: z.string().min(16).max(512),
});

async function loadSession(): Promise<SessionBootstrap> {
  const response = await fetch("/api/session", {
    credentials: "same-origin",
    headers: { Accept: "application/json" },
  });
  if (!response.ok) throw new Error(`session bootstrap failed: ${response.status}`);
  return SessionSchema.parse(await response.json());
}

agentApiUrl 必须是 / 开头的同源路径,不能回退到浏览器直连 localhost。Gateway 在服务端添加 Agent Server 凭据,并为每一次 Thread、Run、State、Cancel 和 Resume 请求做授权。

Thread ID 使用以下键保存到 sessionStorage:

const threadKey =
  `deepagent:${session.environment}:${session.assistantId}:${session.sessionScope}:thread`;

它同时隔离环境、Assistant 和一个由服务端生成的不透明会话范围,避免开发、测试、生产或不同账号共用一个全局 thread_id。但这只是前端体验隔离,不是授权措施;服务端仍然必须查询 Thread Ownership。

5.3 useStream 的当前写法

const stream = useStream<AgentState>({
  apiUrl: session.agentApiUrl,
  assistantId: session.assistantId,
  threadId,
  fetch: authenticatedFetch,
  defaultHeaders: { "X-CSRF-Token": session.csrfToken },
  onThreadId: (id) => {
    sessionStorage.setItem(threadKey, id);
    setThreadId(id);
  },
  onCreated: ({ runId: createdRunId }) => setRunId(createdRunId),
});

浏览器只发送消息和 UI 决策,不发送可信身份 Context。tenant_id、user_id、角色、数据范围都必须由 Gateway 根据已经验证的 Principal 生成。

5.4 三层 Streaming 不能混写

Agent Server 到 React 的 Streaming 层次

本文同时出现三种“事件”,必须分层理解:

  1. Python SDK 请求的逻辑模式:stream_mode=[“updates”, “messages”];
  2. Agent Server v2 实际传输的 part 类型:本次观察到 metadata、updates、messages/metadata、messages/complete;
  3. React Hook 的投影视图:stream.messages、stream.toolCalls、stream.interrupts、stream.subagents 和 stream.values。

前端不应该自己把原始 SSE 字符串拼成 Agent 状态,useStream 已经负责装配消息、Tool 和 Interrupt。另一方面,Hook 暴露的投影也不是服务端所有原始事件的逐条镜像。

在锁定版本中,SubAgent 状态的精确联合类型是:

running | complete | error

不要凭经验增加 completed、failed 等字段。SDK 升级后,让 TypeScript 编译器和契约测试告诉我们哪里发生了变化。

5.5 HITL 适配、Resume 授权与安全渲染

不同层可能使用 action_requests 或 actionRequests。前端在适配边界使用 Zod 验证并归一化,而不是在组件中到处写可选链。审批卡展示 Tool 名、参数和参数 SHA-256,并显式传入 Interrupt ID。

await stream.respond(
  { decisions },
  {
    interruptId: stream.interrupt.id,
    metadata: { ui_action_hash: actionHash },
  },
);

ui_action_hash 只是客户端提交值。Gateway 必须拿服务端保存的待审批参数重新计算并比较,不能信任浏览器给出的哈希。多个待审批 Action 需要逐项展示并产生一一对应的 Decision,不能只批准第一个。

消息、Tool 输出和异常可能包含密钥、ANSI 转义、双向控制字符或超长内容。示例渲染器会:

  • 对 token、secret、password、authorization、api_key、cookie 等键脱敏;
  • 删除 ANSI 和双向文本控制字符;
  • 将单段输出限制为 8,000 字符;
  • 使用 React
     文本节点渲染,不插入原始 HTML。

这只是 UI 防线。服务端日志、Trace、数据库和对象存储仍需分别执行数据分类与脱敏。

5.6 前端构建结果

cd source/_posts/deepagent/examples/p14_production_frontend/frontend
npm run typecheck
npm run build
npm audit --omit=dev

本次结果:

TypeScript typecheck: passed
Vite production build: passed
438 modules transformed
dist/assets/index-DP9GDrU0.js: 507.45 kB, gzip 148.89 kB
production dependency audit: 0 vulnerabilities

Vite 对依赖中的 "use client" 指令给出忽略提示,并提示主 Chunk 大于 500 kB;两者没有导致构建失败。真实产品应按路由拆包,避免聊天页首屏加载全部 SDK 能力。npm audit 只能检查已收录的依赖漏洞,不能证明认证、授权、XSS、CSRF 和业务逻辑安全。

6. 多租户、安全与状态治理

6.1 租户身份必须来自认证主体

多租户可信请求链路

下面三类数据经常被混淆:

数据 用途 是否可信身份来源
Runtime Context Tool 执行期间使用的租户、用户和权限范围 只有 Gateway 根据认证主体生成时才可信
Run/Thread Metadata 搜索、标签、观测和运营分析 不能单独作为授权依据
浏览器请求字段 UI 输入、筛选条件、审批决策 默认不可信,必须验证

context_schema=TenantContext 只校验数据形状,不会完成 Authentication。客户端完全可以提交一个结构合法但身份伪造的 Context。因此 Gateway 的实现原则是忽略客户端身份声明:

def runtime_context_from_principal(
    principal: AuthPrincipal,
    client_context: dict[str, Any] | None = None,
) -> dict[str, str]:
    """Ignore client identity claims and derive Runtime Context from authentication."""

    del client_context
    if not principal.tenant_id or not principal.user_id:
        raise PermissionError("authenticated tenant_id and user_id are required")
    return {"tenant_id": principal.tenant_id, "user_id": principal.user_id}

gateway_policy.py 是一个经过单元测试的授权策略契约,不是完整可部署的 OAuth/OIDC Gateway。真实 Gateway 还要实现身份提供商集成、Cookie 签名、CSRF、密钥轮换、代理和审计落库。

6.2 Thread ID 不能充当授权令牌

每一次 Thread 相关操作都应执行对象级授权:

def authorize_thread(principal: AuthPrincipal, ownership: ThreadOwnership) -> None:
    if principal.tenant_id != ownership.tenant_id:
        raise PermissionError("thread belongs to another tenant")
    if principal.user_id != ownership.user_id and "thread:admin" not in principal.roles:
        raise PermissionError("thread belongs to another user")

需要覆盖的接口不仅是“读取消息”,还包括:

  • 创建 Run;
  • 获取 State 和 History;
  • 订阅 Stream;
  • Cancel Run;
  • Resume Interrupt;
  • 删除 Thread;
  • 获取附件和 Trace 链接。

只在创建 Thread 时写入 metadata.tenant_id,之后不校验所有权,仍然会产生 IDOR 越权漏洞。

6.3 审批不是一个普通按钮

生产审批至少绑定以下信息:

字段 原因
thread_id、Interrupt ID 防止批准错误任务
tenant_id 防止跨租户 Resume
operation_id 支持审计和幂等
Tool 参数哈希 防止审核后参数被替换
审核角色 例如 report:approve
过期时间 避免长期悬挂审批被意外执行

示例 authorize_approval() 会验证所有权、角色、Thread、租户、过期时间和参数哈希。单元测试覆盖错误角色、跨 Thread、跨租户、过期和哈希变化。客户端 Metadata 可以帮助审计,却不能替代这些服务端检查。

6.4 Checkpointer、Store 和业务数据库的边界

存储 保存什么 不应该承担什么
Checkpointer Thread State、Graph 位置、Interrupt、Checkpoint 业务订单真相、无限期附件归档
Store 跨 Thread 的 Memory、用户偏好、可检索数据 认证系统、支付或发布事务
业务数据库 报告、订单、操作状态、幂等记录和审计 Agent 内部推理过程的全部细节

状态治理必须定义 TTL、删除流程、法律保留、附件生命周期、租户迁移和备份恢复。删除前端 sessionStorage 里的 Thread ID 只会创建新会话,不会删除服务端数据。

7. 可观测性、部署与测试

7.1 可观测性需要四类信号

只看到一条完整 Trace,仍然无法回答系统是否健康。至少需要四类信号:

信号 建议观测项
Trace Run、模型、Tool、SubAgent、Middleware、Interrupt、Resume
Metrics 成功率、首事件耗时、Run 总耗时、Token、Tool 错误率、审批等待时长
Logs request_id、thread_id、run_id、Tool、错误分类、租户匿名标识
Alerts 队列堆积、最老待执行任务年龄、模型限流、Checkpoint 失败、Cancel 失败

平均延迟很容易掩盖排队问题。对 Agent Server 尤其要分开记录:

request_received_at
run_created_at
run_started_at
first_event_at
run_ended_at

由此可以计算创建耗时、队列等待、首事件耗时和执行耗时。还应直接监控“最老待执行 Run 的年龄”,因为队列长度相同并不代表用户等待时间相同。

7.2 LangSmith Trace 与隐私边界

本地启用 Trace 时使用环境变量,不把 Key 写入代码:

LANGSMITH_TRACING=true
LANGSMITH_API_KEY=your-key
LANGSMITH_PROJECT=deepagent-production

如果组织使用自定义 Endpoint,还要显式配置对应地址。生产中应根据数据分类决定是否采样、是否记录输入输出、哪些字段脱敏以及 Trace 保留多久。Tool 参数可能包含客户数据,模型消息也可能包含提示词注入内容;“为了排错全部上传”不是合规策略。

Trace ID、request_id、thread_id 和 run_id 应在 Gateway、Agent Server、业务 Tool 与审计日志中关联,但不要把原始租户名、邮箱或访问令牌当作公共标签,避免高基数和隐私泄漏。

7.3 本地、Standalone 与托管部署

形态 适用场景 关键边界
langgraph dev 本地开发、协议调试 noop auth、开发 Runtime,不能作为生产服务
自建/Standalone 需要自主管理网络和数据平面 必须自建认证、持久化、队列、升级、备份和观测
托管部署 希望使用平台提供的运行与控制平面 仍需验证租户授权、数据合规、网络和成本

生产迁移不能只替换启动命令。需要重新验证 Checkpointer、跨 Worker 恢复、滚动升级、队列公平性、容量、凭据注入、健康检查、备份恢复和数据删除。部署产品和配置项更新较快,应以目标版本官方文档为准。

7.4 测试金字塔与本次验收

本例把测试拆成三层:

  1. 单元层:确定性 Tool、模型快照、Approve、Reject、Gateway 授权;
  2. 服务层:真实 Agent Server、REST、SDK、v2 Stream、State、HITL、Cancel、重启恢复;
  3. 前端层:TypeScript、Vite 构建、依赖审计和运行时 Schema。

本次执行结果:

Ruff: passed
compileall: passed
Pytest: 9 passed
Agent Server REST/SDK/HITL/Cancel: passed
Agent Server restart/resume: passed
TypeScript typecheck: passed
Vite production build: passed
npm production audit: 0 vulnerabilities

在线模型入口仍然保留:

P14_MODEL_MODE=live
DEEPAGENT_API_KEY=...
DEEPAGENT_BASE_URL=...
DEEPAGENT_MODEL=...

没有密钥时,默认 deterministic 模式不会访问网络。切换到 live 后,仍应断言结构行为——模型确实产生目标 Tool Call、Interrupt 出现在执行前、Resume 使用原 Thread——而不是断言随机自然语言全文。在线输出必须由发布所使用的代码与锁文件重新运行并脱敏,不能复用其他版本的结果。

7.5 常见故障定位

现象 优先检查
/ok 正常但 Graph 不可调用 langgraph.json 路径、依赖安装、Graph 导入异常
前端刷新后出现空会话 Session Scope、Assistant、环境和 Thread Key 是否一致
能读取其他用户 Thread Gateway 是否对每个对象操作执行所有权校验
Approve 后参数变化 Resume 前是否比较服务端保存的参数哈希
点击断开后任务仍运行 是否调用了 disconnect() 而不是 Cancel
Cancel 后外部系统仍改变 Tool 是否已经发出不可撤销请求,是否实现幂等与状态查询
消息有结果但 Tool 面板为空 服务端是否发送 tools channel,SDK 版本是否匹配
重启后无法恢复 启动目录、开发存储、Checkpointer 配置和关停刷新是否一致
Trace 有链路但用户仍觉得慢 拆分队列等待、首事件、模型和 Tool 耗时

8. 总结

Deep Agent 的服务化不是给 invoke() 套一个 HTTP 接口,而是把有状态、可中断、可恢复的执行交给 Agent Server 管理,并在浏览器与内部服务之间建立可信安全边界。

本篇完成了以下可核验工作:

  1. 明确区分 Graph、Assistant、Thread 和 Run;
  2. 使用独立 langgraph.json 与依赖文件导出可部署 Graph;
  3. 使用确定性 Tool-Calling 模型验证真实 Agent Server 协议;
  4. 验证 v2 Streaming、State、History、HITL Resume 和 Run Cancel;
  5. 完全重启本地 Agent Server 后,从原 Thread 恢复待审批任务;
  6. 将前端迁移到 @langchain/react,移除不安全的类型断言;
  7. 使用同源 Gateway 会话、隔离 Thread Key、Zod 适配和安全渲染;
  8. 通过可执行策略测试 Thread 所有权和审批授权;
  9. 给出 Trace、指标、日志、告警、部署和数据治理边界。

同时也保留了明确的未验证项:示例发布 Tool 没有真实副作用,本地重启结果不能代表生产数据库与多 Worker,gateway_policy.py 不是完整身份系统,依赖审计也不是安全测试的替代品。

至此,Deep Agents 系列从框架定位、模型与 Tool、Backend、Sandbox、Skills、Memory、SubAgent、Middleware、HITL、ACP、综合项目,一直走到了服务化与生产治理。真正上线时,最重要的不是再增加一个 Agent 名称,而是让每个身份、状态、操作和恢复路径都有清晰边界,并且能够被测试和观测。


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