前十三篇文章已经完成了 Deep Agent 的构建、工具接入、执行隔离、上下文工程、多智能体协作、人工审批和综合项目。但是,能够在 Python 进程里执行 agent.invoke(),与能够安全地交付给用户之间,还有一段不短的距离。
生产系统面对的不是一次函数调用,而是可能持续数分钟、跨越多个 Tool 和 SubAgent、等待人工审批、经历浏览器断线甚至服务重启的一次运行。我们必须回答下面这些问题:
- Agent 如何以稳定协议对外提供服务?
- 用户刷新页面以后,如何回到原来的任务?
- 审批发生在另一个 HTTP 请求中时,如何继续原来的执行点?
- 浏览器断开、停止接收事件和取消服务端任务有什么区别?
- 知道一个 thread_id,是否就能读取或恢复这个 Thread?
- 出现慢请求时,究竟是模型慢、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 上的一次执行 | 排队、运行、中断、完成或取消 | “检查服务”“发布报告” |

一个 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。

一条可信的请求链路应当是:
- 浏览器携带站点登录 Cookie 请求同源 Gateway/BFF;
- Gateway 验证登录态、CSRF、租户、角色和资源所有权;
- Gateway 根据认证主体生成可信 Runtime Context;
- Gateway 使用服务端凭据访问 Agent Server;
- Agent Server 管理 Assistant、Thread、Run、队列和 Checkpoint;
- Worker 执行 Graph、模型、Tool、MCP、Sandbox 和 SubAgent;
- 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 依次执行:
- 按 graph_id=deepagent 搜索唯一默认 Assistant;
- 创建健康检查 Thread;
- 使用 Python SDK 发起 v2 Stream;
- 记录 Run ID、首事件耗时、总耗时和事件类型;
- 读取当前 State 和有界 History;
- 创建审批 Thread,验证 publish_report Interrupt;
- 在相同 Thread 上 Approve 并 Resume;
- 创建 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 中断与恢复的状态机

当模型请求 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
这三个动作经常被混为一谈:

| 动作 | 浏览器连接 | 服务端 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
当前 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 不能混写

本文同时出现三种“事件”,必须分层理解:
- Python SDK 请求的逻辑模式:stream_mode=[“updates”, “messages”];
- Agent Server v2 实际传输的 part 类型:本次观察到 metadata、updates、messages/metadata、messages/complete;
- 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 测试金字塔与本次验收
本例把测试拆成三层:
- 单元层:确定性 Tool、模型快照、Approve、Reject、Gateway 授权;
- 服务层:真实 Agent Server、REST、SDK、v2 Stream、State、HITL、Cancel、重启恢复;
- 前端层: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 管理,并在浏览器与内部服务之间建立可信安全边界。
本篇完成了以下可核验工作:
- 明确区分 Graph、Assistant、Thread 和 Run;
- 使用独立 langgraph.json 与依赖文件导出可部署 Graph;
- 使用确定性 Tool-Calling 模型验证真实 Agent Server 协议;
- 验证 v2 Streaming、State、History、HITL Resume 和 Run Cancel;
- 完全重启本地 Agent Server 后,从原 Thread 恢复待审批任务;
- 将前端迁移到 @langchain/react,移除不安全的类型断言;
- 使用同源 Gateway 会话、隔离 Thread Key、Zod 适配和安全渲染;
- 通过可执行策略测试 Thread 所有权和审批授权;
- 给出 Trace、指标、日志、告警、部署和数据治理边界。
同时也保留了明确的未验证项:示例发布 Tool 没有真实副作用,本地重启结果不能代表生产数据库与多 Worker,gateway_policy.py 不是完整身份系统,依赖审计也不是安全测试的替代品。
至此,Deep Agents 系列从框架定位、模型与 Tool、Backend、Sandbox、Skills、Memory、SubAgent、Middleware、HITL、ACP、综合项目,一直走到了服务化与生产治理。真正上线时,最重要的不是再增加一个 Agent 名称,而是让每个身份、状态、操作和恢复路径都有清晰边界,并且能够被测试和观测。