核心摘要

Agent Harness 实现是模型提案与权威副作用之间的运行时代码。它负责持久化运行状态、收窄能力、执行权限策略、暂停并等待持久化审批、用稳定操作身份调用工具、记录可观察证据,并在无法确定外部写入结果时进入对账流程。

MCP 可以标准化能力发现,LangGraph 可以提供 Checkpoint 和 Interrupt,但二者都不替应用完成最终用户授权与下游业务不变量。正确顺序是先定义契约,再把契约映射到框架。

目录

核心要点

  • 模型只能提议操作,确定性控制负责判断是否执行以及如何执行。
  • 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 不保存多余敏感内容也能还原关键决定

第一个版本可以只有一个进程和一个数据库。服务是否拆分可以后定,责任边界不能省略。

分离提案、策略与副作用

可靠的执行路径需要保留三类不同制品:

  1. 提案(Proposal):模型想调用什么,包括工具版本与类型化参数。
  2. 策略决定(Policy Decision):当前已认证操作者是否能以该用途对该资源执行此操作。
  3. 副作用结果(Effect Result):下游系统实际提交了什么。
sequenceDiagram participant U as 用户 participant H as Harness participant M as 模型 participant P as 策略服务 participant X as 工具执行器 participant S as 状态库 U->>H: 请求与已认证身份 H->>M: 有界上下文与允许的 Schema M-->>H: 类型化工具提案 H->>H: 校验 Schema、状态与预算 H->>P: 操作者、用途、资源与操作 P-->>H: 拒绝、允许或要求审批 H->>S: 持久化决定与操作键 H->>X: 已授权调用与操作键 X-->>H: 已提交、失败或未知 H->>S: 比较并提交结果

不要把三者压缩成一个 tool_call 对象。JSON 合法不代表资源属于当前租户;已审批操作可能因资源变化而失效;HTTP 成功也可能携带业务失败。

分别持久化运行状态与副作用

持久化运行状态回答“工作流从哪里恢复”,Effect Journal 回答“工作流之外可能已经发生了什么”。两者都不可缺少。

sql
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 可能同时推进同一运行。副作用状态应显式建模:

text
prepared -> dispatched -> committed
                       -> failed
                       -> unknown -> reconciled_committed | reconciled_absent

unknown 不是普通错误文案。它防止系统把超时误解为可以重新付款、部署、发信、建工单或写仓库。

实现一个最小 Harness Kernel

下面的 Python 3.11 示例仅依赖标准库,可以直接运行。示例刻意不调用模型和网络:任何模型供应商或 MCP Client 都必须先把输出适配为同一个 Proposal 契约。

python
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)}))

预期输出:

json
{"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 监管

由此得到三条实现规则:

  1. 每个非确定性调用或副作用单独放进 Task 或 Node。
  2. 假设 interrupt() 之前的代码会在恢复后再次执行。
  3. 生产环境使用持久化 Checkpointer;内存 Saver 只能演示流程,不能抵御进程丢失。

LangGraph 当前提供 exit、async 和 sync 三种 Durability Mode,对持久性和执行开销做不同取舍。它们是随版本变化的框架行为,不是可移植的 Harness 标准。

把 MCP 放在策略边界之后

MCP 标准化发现与调用,应该位于 Harness 策略边界之后。为每个已发现工具建立注册快照:

json
{
  "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
}

执行顺序应为:

  1. 只向允许列表中的 Server 发现能力。
  2. 校验并固定 Schema 快照,再暴露给模型。
  3. 用该快照校验模型提案。
  4. 检查 Actor、Tenant、Resource、Purpose 和当前策略。
  5. 必要时获取绑定不可变副作用的审批。
  6. 使用范围受限的身份和有界超时调用 Server。
  7. 校验、脱敏并限制结果大小,再把结果放回模型上下文。

对于 HTTP Transport,MCP 2026-07-28 授权规范要求 Token 绑定目标 Resource,并禁止接收或转发为其他 Resource 签发的 Token。stdio 不使用这套 HTTP 授权流程,凭证来自进程环境。无论哪种 Transport,Tool Server 都必须独立执行授权;面向模型的 Schema 不是权限。

只有形成真实隔离时才按信任边界拆 Server,例如只读客服数据、可写工单操作和外部网络访问。如果多个 Server 仍共享同一个高权限凭证,名字分开并不等于身份隔离。

把人机审批设计为持久化数据

可靠审批是一条持久化数据,不是阻塞终端的提示。保存经过签名或服务端认证的决策信封:

json
{
  "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 之间不存在通用回滚;应按业务域设计补偿操作,也不能把“补偿成功”当成原副作用从未发生的证明。

记录证据但不泄露数据

可观测性应解释控制流与结果,但不能变成第二个敏感数据仓库。应用可以维护如下稳定事件:

json
{
  "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 放在策略边界之后;把每次派发后超时视为证据与对账问题,而不是自动重试信号。

相关资源

一手来源