核心摘要
Agent Harness 实现是模型提案与权威副作用之间的运行时代码。它负责持久化运行状态、收窄能力、执行权限策略、暂停并等待持久化审批、用稳定操作身份调用工具、记录可观察证据,并在无法确定外部写入结果时进入对账流程。
MCP 可以标准化能力发现,LangGraph 可以提供 Checkpoint 和 Interrupt,但二者都不替应用完成最终用户授权与下游业务不变量。正确顺序是先定义契约,再把契约映射到框架。
目录
- 先定义实现契约
- 分离提案、策略与副作用
- 分别持久化运行状态与副作用
- 实现一个最小 Harness Kernel
- 把契约映射到 LangGraph
- 把 MCP 放在策略边界之后
- 把人机审批设计为持久化数据
- 正确处理结果未知
- 记录证据但不泄露数据
- 上线前验证实现
- 常见问题
核心要点
- 模型只能提议操作,确定性控制负责判断是否执行以及如何执行。
- Run Checkpoint 与外部副作用属于两个不同的一致性域。
- 每个写操作都需要稳定 Operation Key 和显式的
unknown结果。 - 审批必须绑定精确的操作者、资源、参数、Schema、策略版本和有效期。
- MCP Schema 校验消息形态,不证明最终用户权限或业务语义。
- 框架重放可能重新进入节点,因此非确定性操作和副作用必须隔离。
- 记录可观察事件与结果摘要,不记录私有思维链或原始 Secret。
- 重启、重复投递、过期审批、拒绝、超时等场景通过后才能发布。
先定义实现契约
Agent Harness 应从责任归属开始,而不是从框架选型开始。架构篇解释控制、执行、状态和证据四个平面;本文只负责把这些平面落到接口、数据和失败规则。
编码前先写一页实现契约:
| 关注点 | 必须负责的组件 | 最小不变量 |
|---|---|---|
| 身份 | 请求网关或身份服务 | 选择能力前解析 Principal 与 Tenant |
| 提案 | 模型适配器 | 输出类型化 Proposal,不能自行标记已授权 |
| 能力注册表 | Harness 控制面 | 固定工具名、Schema 版本、风险等级与 Executor |
| 策略 | 独立 Policy Service | 检查操作者、用途、资源、操作和当前策略 |
| 审批 | 持久化审批服务 | 把决定绑定到一个不可变的拟执行副作用 |
| 执行 | 范围受限的 Worker 或 Tool Server | 执行参数、凭证、超时与输出上限 |
| 运行状态 | 版本化状态库 | 只接受符合预期版本的状态迁移 |
| 副作用状态 | Effect Journal 与下游服务 | 对每个语义操作去重或对账 |
| 证据 | Event Sink | 不保存多余敏感内容也能还原关键决定 |
第一个版本可以只有一个进程和一个数据库。服务是否拆分可以后定,责任边界不能省略。
分离提案、策略与副作用
可靠的执行路径需要保留三类不同制品:
- 提案(Proposal):模型想调用什么,包括工具版本与类型化参数。
- 策略决定(Policy Decision):当前已认证操作者是否能以该用途对该资源执行此操作。
- 副作用结果(Effect Result):下游系统实际提交了什么。
不要把三者压缩成一个 tool_call 对象。JSON 合法不代表资源属于当前租户;已审批操作可能因资源变化而失效;HTTP 成功也可能携带业务失败。
分别持久化运行状态与副作用
持久化运行状态回答“工作流从哪里恢复”,Effect Journal 回答“工作流之外可能已经发生了什么”。两者都不可缺少。
CREATE TABLE agent_runs (
run_id TEXT PRIMARY KEY,
state_version INTEGER NOT NULL,
status TEXT NOT NULL,
principal_id TEXT NOT NULL,
tenant_id TEXT NOT NULL,
policy_version TEXT NOT NULL,
checkpoint_json TEXT NOT NULL
);
CREATE TABLE agent_effects (
operation_key TEXT PRIMARY KEY,
run_id TEXT NOT NULL,
call_id TEXT NOT NULL,
tool_name TEXT NOT NULL,
tool_version TEXT NOT NULL,
argument_digest TEXT NOT NULL,
status TEXT NOT NULL,
result_digest TEXT,
external_reference TEXT
);
更新 state_version 时应使用 Compare-and-Set,否则两个 Worker 可能同时推进同一运行。副作用状态应显式建模:
prepared -> dispatched -> committed
-> failed
-> unknown -> reconciled_committed | reconciled_absent
unknown 不是普通错误文案。它防止系统把超时误解为可以重新付款、部署、发信、建工单或写仓库。
实现一个最小 Harness Kernel
下面的 Python 3.11 示例仅依赖标准库,可以直接运行。示例刻意不调用模型和网络:任何模型供应商或 MCP Client 都必须先把输出适配为同一个 Proposal 契约。
from __future__ import annotations
from dataclasses import dataclass, field
from hashlib import sha256
import json
from typing import Callable
@dataclass(frozen=True)
class Principal:
subject: str
tenant: str
scopes: frozenset[str]
@dataclass(frozen=True)
class Proposal:
call_id: str
tool: str
tool_version: str
arguments: dict[str, object]
@dataclass(frozen=True)
class Approval:
proposal_digest: str
subject: str
approver: str
expires_at: int
policy_version: str
@dataclass
class Run:
run_id: str
principal: Principal
policy_version: str
authorized_approvers: frozenset[str]
max_steps: int
steps: int = 0
effects: dict[str, dict[str, object]] = field(default_factory=dict)
events: list[dict[str, object]] = field(default_factory=list)
TOOLS = {
"read_ticket": {"scope": "ticket:read", "risk": "read"},
"close_ticket": {"scope": "ticket:write", "risk": "write"},
}
def digest(proposal: Proposal) -> str:
payload = {
"call_id": proposal.call_id,
"tool": proposal.tool,
"tool_version": proposal.tool_version,
"arguments": proposal.arguments,
}
encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
return sha256(encoded).hexdigest()
def authorize(run: Run, proposal: Proposal) -> str:
metadata = TOOLS.get(proposal.tool)
if metadata is None:
return "deny"
if metadata["scope"] not in run.principal.scopes:
return "deny"
if proposal.arguments.get("tenant") != run.principal.tenant:
return "deny"
return "approval_required" if metadata["risk"] == "write" else "allow"
def execute(
run: Run,
proposal: Proposal,
tool: Callable[[dict[str, object], str], dict[str, object]],
*,
now: int,
approval: Approval | None = None,
) -> dict[str, object]:
run.steps += 1
if run.steps > run.max_steps:
raise RuntimeError("step budget exceeded")
decision = authorize(run, proposal)
run.events.append({"kind": "policy", "call_id": proposal.call_id,
"decision": decision})
if decision == "deny":
raise PermissionError("tool proposal denied")
proposal_digest = digest(proposal)
if decision == "approval_required":
valid = (
approval is not None
and approval.proposal_digest == proposal_digest
and approval.subject == run.principal.subject
and approval.approver in run.authorized_approvers
and approval.policy_version == run.policy_version
and approval.expires_at >= now
)
if not valid:
raise PermissionError("valid approval required")
operation_key = (
f"{run.run_id}:{proposal.call_id}:"
f"{proposal.tool}:{proposal.tool_version}"
)
previous = run.effects.get(operation_key)
if previous is not None:
return previous
run.effects[operation_key] = {"status": "dispatched"}
try:
result = tool(proposal.arguments, operation_key)
except TimeoutError:
run.effects[operation_key] = {"status": "unknown"}
raise
except Exception:
run.effects[operation_key] = {"status": "failed"}
raise
record = {
"status": "committed",
"result": result,
"result_digest": sha256(
json.dumps(result, sort_keys=True).encode()
).hexdigest(),
}
run.effects[operation_key] = record
run.events.append({"kind": "effect", "operation_key": operation_key,
"status": "committed"})
return record
external_calls: dict[str, dict[str, object]] = {}
def close_ticket(
arguments: dict[str, object],
operation_key: str,
) -> dict[str, object]:
if operation_key not in external_calls:
external_calls[operation_key] = {
"ticket_id": arguments["ticket_id"],
"status": "closed",
}
return external_calls[operation_key]
principal = Principal(
subject="user-42",
tenant="acme",
scopes=frozenset({"ticket:write"}),
)
run = Run(
run_id="run-7",
principal=principal,
policy_version="policy-3",
authorized_approvers=frozenset({"reviewer-5"}),
max_steps=3,
)
proposal = Proposal(
call_id="call-9",
tool="close_ticket",
tool_version="2",
arguments={"tenant": "acme", "ticket_id": "T-100"},
)
approval = Approval(
proposal_digest=digest(proposal),
subject="user-42",
approver="reviewer-5",
expires_at=2_000_000_000,
policy_version="policy-3",
)
first = execute(run, proposal, close_ticket, now=1_900_000_000,
approval=approval)
replayed = execute(run, proposal, close_ticket, now=1_900_000_001,
approval=approval)
assert first == replayed
assert len(external_calls) == 1
assert first["status"] == "committed"
print(json.dumps({"result": first, "external_calls": len(external_calls)}))
预期输出:
{"result": {"status": "committed", "result": {"ticket_id": "T-100", "status": "closed"}, "result_digest": "<sha256>"}, "external_calls": 1}
该示例只证明一个窄契约:范围受限的写操作需要匹配审批,重放相同操作不会重复调用下游副作用。生产环境应把内存字典替换为事务存储,认证审批人,并让下游服务执行同一 Operation Key。
把契约映射到 LangGraph
LangGraph 可以实现工作流边界,但节点设计必须服从其重放语义。当前 LangGraph 文档明确说明:恢复 Graph 时会从合适的 Node 或 Entrypoint 重新执行,不是从中断的那一行继续。
应显式映射每项责任:
| Harness 契约 | LangGraph 机制 | 应用仍需负责 |
|---|---|---|
| 版本化运行游标 | Checkpointer 与稳定 thread_id |
租户隔离、保留、迁移、并发 |
| 持久化暂停 | interrupt() 与 Command(resume=...) |
审批人认证和审批绑定 |
| 副作用隔离 | Task 或独立 Node | 幂等键、对账、下游不变量 |
| 恢复策略 | 使用同一 Thread 身份恢复 | 区分可重试、终止与未知结果 |
| 关闭 | 受支持版本中的协作 Drain | 硬超时、任务取消、Worker 监管 |
由此得到三条实现规则:
- 每个非确定性调用或副作用单独放进 Task 或 Node。
- 假设
interrupt()之前的代码会在恢复后再次执行。 - 生产环境使用持久化 Checkpointer;内存 Saver 只能演示流程,不能抵御进程丢失。
LangGraph 当前提供 exit、async 和 sync 三种 Durability Mode,对持久性和执行开销做不同取舍。它们是随版本变化的框架行为,不是可移植的 Harness 标准。
把 MCP 放在策略边界之后
MCP 标准化发现与调用,应该位于 Harness 策略边界之后。为每个已发现工具建立注册快照:
{
"server_id": "support-read",
"tool_name": "get_ticket",
"tool_version": "7",
"schema_digest": "sha256:...",
"risk_class": "sensitive_read",
"required_scope": "ticket:read",
"allowed_tenants": ["acme"],
"max_result_bytes": 65536
}
执行顺序应为:
- 只向允许列表中的 Server 发现能力。
- 校验并固定 Schema 快照,再暴露给模型。
- 用该快照校验模型提案。
- 检查 Actor、Tenant、Resource、Purpose 和当前策略。
- 必要时获取绑定不可变副作用的审批。
- 使用范围受限的身份和有界超时调用 Server。
- 校验、脱敏并限制结果大小,再把结果放回模型上下文。
对于 HTTP Transport,MCP 2026-07-28 授权规范要求 Token 绑定目标 Resource,并禁止接收或转发为其他 Resource 签发的 Token。stdio 不使用这套 HTTP 授权流程,凭证来自进程环境。无论哪种 Transport,Tool Server 都必须独立执行授权;面向模型的 Schema 不是权限。
只有形成真实隔离时才按信任边界拆 Server,例如只读客服数据、可写工单操作和外部网络访问。如果多个 Server 仍共享同一个高权限凭证,名字分开并不等于身份隔离。
把人机审批设计为持久化数据
可靠审批是一条持久化数据,不是阻塞终端的提示。保存经过签名或服务端认证的决策信封:
{
"approval_id": "approval-17",
"run_id": "run-7",
"call_id": "call-9",
"actor": "user-42",
"approver": "reviewer-5",
"resource": "ticket:T-100",
"tool_schema_digest": "sha256:...",
"argument_digest": "sha256:...",
"policy_version": "policy-3",
"decision": "approve",
"expires_at": "<timestamp>"
}
Worker 应先持久化 waiting_for_approval,释放计算资源,收到决定后再恢复同一次 Run。派发前重新校验每个绑定字段;只要参数、资源状态、Tool Schema、策略、审批权限或有效期变化,就拒绝旧决定。
人类复核与自动 Guardrail 解决不同问题。Guardrail 可以拒绝畸形输入或工具结果;Approval 记录对某个具体敏感副作用的可追责同意。两者都不能替代下游授权。
正确处理结果未知
恢复策略取决于失败发生的位置:
| 失败位置 | 已知事实 | 正确动作 |
|---|---|---|
| 派发前 | 工具未被调用 | 按当前策略重新检查后恢复或重试 |
| 模型调用期间 | 不应存在权威副作用 | 在模型与预算策略内重试 |
| 工具明确返回失败 | Effect Contract 声明失败 | 记录失败,仅在分类为安全时重试 |
| 派发后超时 | 提交状态未知 | 按 Operation Key 查询下游,禁止盲目重试 |
| 副作用提交后 Checkpoint 失败 | 下游可能已有结果 | 对账并关联已有外部引用 |
| 暂停期间审批过期 | 旧同意无效 | 重算策略并重新申请审批 |
| Tool Schema 变化 | 提案不再符合已审核契约 | 拒绝并创建新提案 |
| Worker 终止 | 最后提交的 Checkpoint 有效 | 使用同一 Run 身份在其他 Worker 恢复 |
Git Branch、数据库事务与工作流 Checkpoint 分别保护不同资源。仓库、邮件、支付和部署 API 之间不存在通用回滚;应按业务域设计补偿操作,也不能把“补偿成功”当成原副作用从未发生的证明。
记录证据但不泄露数据
可观测性应解释控制流与结果,但不能变成第二个敏感数据仓库。应用可以维护如下稳定事件:
{
"event": "tool_effect_resolved",
"run_id": "run-7",
"step_id": "step-4",
"call_id": "call-9",
"operation_key": "run-7:call-9:close_ticket:2",
"tool": "close_ticket",
"tool_version": "2",
"policy_version": "policy-3",
"policy_decision": "allow_after_approval",
"effect_status": "committed",
"result_digest": "sha256:...",
"latency_ms": 84,
"redactions": ["ticket_body", "access_token"]
}
先保持应用事件契约稳定,再映射到当前监控栈支持的 OpenTelemetry GenAI 约定。该约定已经迁移到独立仓库,覆盖 GenAI Client、MCP、Span、Metric 与 Event,但不会替你定义业务结果或保留策略。
不要记录隐藏思维链。按明确的数据保留规则保存有界模型输出、工具提案、策略决定、状态迁移、结果引用、错误与业务结果。
上线前验证实现
实现测试必须在每个控制真正生效的位置证明结果:
| 测试 | 必须断言 |
|---|---|
| 未知工具 | 派发前拒绝 |
| 跨租户资源 | 即使 Schema 合法也拒绝 |
| 审批后参数变化 | 审批失效 |
| 消息重复投递 | 只产生一次语义副作用 |
| 派发后超时 | 状态进入 unknown,不盲目重试 |
| 提交后 Worker 崩溃 | 对账发现已有结果 |
| 策略或 Schema 过期 | 拒绝旧提案与审批 |
| 超大或被污染的结果 | 限制结果,并按不可信数据处理 |
| 步骤或费用耗尽 | 在模型控制之外终止 Run |
| Event 包含 Secret | 执行脱敏或拒绝事件 |
先在无模型条件下运行 Contract Test,再加入代表性端到端场景、故障注入、Shadow Replay 和发布对比。完整评测方法见 Agent Harness 评测指南。模型输出了预期句子,不代表授权、去重和恢复正确。
常见问题
如何实现 Agent Harness?
从类型化 Proposal、已认证 Principal、Capability Registry、Policy Decision、持久化运行状态、Effect Journal、Approval Envelope 和 Event Contract 开始。先实现最小端到端路径并测试每个失败边界,再接入模型供应商、MCP Adapter、工作流框架和分布式 Worker。
实现 Agent Harness 必须使用 LangGraph 吗?
不必须。LangGraph 是实现 Graph State、Checkpoint、Interrupt 与 Resume 的一种选择;持久化工作流引擎、队列驱动状态机或应用服务也能满足相同契约。框架选型不会消除授权、副作用对账、保留、迁移和生产测试责任。
MCP 会授权 Agent 的工具调用吗?
不会。MCP 配置后可以认证和授权协议访问,但应用与 Server 仍要执行最终用户、Tenant、Resource 和 Operation 策略。Tool Schema 只描述结构上接受哪些参数,不能证明当前调用者能关闭这张工单或读取这个文件。
审批应该发生在 Agent Loop 内吗?
Loop 可以发起审批请求,但决定应来自可信服务或已认证复核人,并持久化在模型上下文之外。审批必须绑定精确提案,执行前重新校验。长时间审批应暂停工作流并释放 Worker,而不是保持进程阻塞。
Checkpoint 与 Effect Journal 有什么区别?
Checkpoint 记录工作流进度,Effect Journal 记录外部操作的身份与状态。如果 Worker 在远程 API 提交后、下一个 Checkpoint 前崩溃,仅靠 Checkpoint 无法判断副作用是否发生;必须用 Operation Key 对账下游与 Journal。
总结
当模型提案无法绕过身份、策略、审批、有界执行、持久状态和副作用对账时,Agent Harness 才具备生产基础。先实现契约,再选择框架;把 MCP 放在策略边界之后;把每次派发后超时视为证据与对账问题,而不是自动重试信号。
相关资源
- Harness Engineering:范围与边界
- Agent Harness 架构
- Agent Harness 评测
- MCP 协议指南
- Agent Harness 术语
- Human-in-the-Loop 术语
- Agent Runtime 术语