ReAct 解决了一个关键问题:让模型在“思考、行动、观察”的循环中调用工具并逐步完成任务。但当 Agent 进入真实产品,团队面对的不再只是模型能否完成任务,而是用户能否看见进度、暂停执行、确认高风险操作、从故障中恢复,并追溯每一步发生了什么。
这时,工程重心会从模型循环转向 Agent Harness。Harness 不是另一套推理方法,而是包围模型循环的运行框架:它定义状态、保存事实、执行工具、处理审批,并向前端提供稳定的交互边界。
ReAct 只描述循环,产品还需要运行契约
一个最小 ReAct 循环通常可以概括为:
- 将用户目标和当前上下文发送给模型。
- 模型决定回答用户或调用工具。
- 系统执行工具,把结果作为 observation 返回模型。
- 重复以上过程,直到完成或达到限制。
这个循环适合验证 Agent 是否“能做事”,但直接用于产品会留下几个缺口:
- 状态只存在于内存或消息历史里:进程重启后难以继续。
- 模型输出被当成运行事实:模型说“文件已写入”,不等于文件系统确认写入成功。
- 前端依赖自然语言猜测进度:UI 很难稳定展示“等待审批”或“工具执行失败”。
- 副作用缺少边界:发邮件、付款、删除资源等操作不能只依赖模型自行判断。
- 历史不可审计:只有一串聊天消息,无法解释某次工具调用的参数、结果和审批人。
Agent Harness 要补上的正是这些工程契约。模型可以提出下一步,但运行时必须决定这一步是否允许、如何执行、如何记录,以及执行后状态如何变化。
共享 State Schema:让前后端围绕同一份状态工作
聊天消息不应该是 Agent 状态的唯一载体。更稳定的做法是定义结构化 State Schema,并让后端、持久化层和前端共享它。
下面是一份可以直接保存为 agent-state.schema.json 的最小 JSON Schema。示例假设 Agent 支持暂停、审批、失败恢复和事件追踪;实际项目可以按业务增加租户、权限和任务产物字段。
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.com/schemas/agent-state.schema.json",
"title": "AgentRunState",
"type": "object",
"required": ["run_id", "status", "goal", "version", "events"],
"properties": {
"run_id": { "type": "string", "minLength": 1 },
"status": {
"type": "string",
"enum": ["queued", "running", "waiting_approval", "paused", "completed", "failed"]
},
"goal": { "type": "string" },
"version": { "type": "integer", "minimum": 0 },
"pending_action": {
"type": ["object", "null"],
"required": ["action_id", "tool", "arguments", "risk"],
"properties": {
"action_id": { "type": "string" },
"tool": { "type": "string" },
"arguments": { "type": "object" },
"risk": { "type": "string", "enum": ["low", "high"] }
},
"additionalProperties": false
},
"events": {
"type": "array",
"items": {
"type": "object",
"required": ["seq", "type", "payload"],
"properties": {
"seq": { "type": "integer", "minimum": 1 },
"type": { "type": "string" },
"payload": { "type": "object" }
},
"additionalProperties": false
}
}
},
"additionalProperties": false
}
前端不需要解析“我准备发送邮件,请确认”这句话来判断是否展示按钮。它只需要检查:
const needsApproval = state.status === "waiting_approval";
const canResume = state.status === "paused" || state.status === "failed";
const pendingTool = state.pending_action?.tool;
这条边界非常重要:自然语言负责解释,结构化状态负责控制 UI。即使模型更换、提示词调整或输出措辞变化,产品交互仍然稳定。
Schema 还应带版本。生产系统可能需要采用乐观并发控制:前端提交审批时携带当前 version,后端只在版本匹配时更新状态,避免用户批准了已经被替换的旧操作。
运行事实不能由模型宣布
Agent 系统里至少存在三类信息:
- 模型提议:模型建议调用什么工具、使用哪些参数。
- 运行事实:工具是否真正启动、返回什么、是否超时、产生了哪些资源。
- 产品状态:当前运行是执行中、等待审批、失败还是完成。
其中只有 Harness 可以写入运行事实。模型生成的工具调用只是提案,不能直接成为“邮件已发送”或“部署已完成”的证据。
可以把每次变化记录成追加事件:
{"seq":1,"type":"run.started","payload":{"goal":"生成并发送周报"}}
{"seq":2,"type":"action.proposed","payload":{"action_id":"a-17","tool":"send_email"}}
{"seq":3,"type":"approval.requested","payload":{"action_id":"a-17"}}
{"seq":4,"type":"approval.granted","payload":{"action_id":"a-17","actor":"user-42"}}
{"seq":5,"type":"tool.succeeded","payload":{"action_id":"a-17","message_id":"m-903"}}
{"seq":6,"type":"run.completed","payload":{}}
这种事件序列同时服务于四个目标:恢复运行、刷新 UI、审计副作用,以及定位失败。需要注意,日志里不应无条件保存访问令牌、完整邮件正文或用户隐私数据;工具参数和结果应经过字段级脱敏,并设置保留周期。
一个可运行的最小 Harness
下面的 Python 示例没有绑定具体模型 SDK,而是集中展示 Harness 的职责。它会拦截高风险工具、等待人工确认、记录事件,并在进程重启后从 JSON 文件恢复。
将代码保存为 harness.py,使用 Python 3.10 及以上版本运行。首次运行会停在审批状态,再次加上 --approve 即可继续。
from __future__ import annotations
import argparse
import json
import uuid
from pathlib import Path
from typing import Any
STATE_FILE = Path("run-state.json")
def save(state: dict[str, Any]) -> None:
tmp = STATE_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
tmp.replace(STATE_FILE)
def emit(state: dict[str, Any], event_type: str, payload: dict[str, Any]) -> None:
state["events"].append({
"seq": len(state["events"]) + 1,
"type": event_type,
"payload": payload,
})
state["version"] += 1
save(state)
def load_or_create() -> dict[str, Any]:
if STATE_FILE.exists():
return json.loads(STATE_FILE.read_text(encoding="utf-8"))
state = {
"run_id": str(uuid.uuid4()),
"status": "running",
"goal": "生成周报并发送给团队",
"version": 0,
"pending_action": None,
"events": [],
}
emit(state, "run.started", {"goal": state["goal"]})
return state
def propose_next_action(state: dict[str, Any]) -> dict[str, Any]:
# 实际项目可在这里调用模型,并校验其结构化输出。
return {
"action_id": str(uuid.uuid4()),
"tool": "send_email",
"arguments": {
"to": "team@example.com",
"subject": "Weekly report",
"body": "Build is green. Two issues remain open.",
},
"risk": "high",
}
def execute_tool(action: dict[str, Any]) -> dict[str, Any]:
# 示例不发送真实邮件;接入邮件 API 时应使用 action_id 作为幂等键。
return {"message_id": f"demo-{action['action_id']}", "accepted": True}
def run(approved: bool) -> None:
state = load_or_create()
if state["status"] == "completed":
print("Run already completed.")
return
action = state["pending_action"]
if action is None:
action = propose_next_action(state)
state["pending_action"] = action
emit(state, "action.proposed", {
"action_id": action["action_id"],
"tool": action["tool"],
"risk": action["risk"],
})
if action["risk"] == "high" and not approved:
state["status"] = "waiting_approval"
emit(state, "approval.requested", {
"action_id": action["action_id"],
"tool": action["tool"],
"arguments": action["arguments"],
})
print("Approval required. Re-run with --approve.")
return
if approved:
emit(state, "approval.granted", {"action_id": action["action_id"]})
state["status"] = "running"
save(state)
try:
result = execute_tool(action)
emit(state, "tool.succeeded", {
"action_id": action["action_id"],
"result": result,
})
state["pending_action"] = None
state["status"] = "completed"
emit(state, "run.completed", {})
print(json.dumps(result, indent=2))
except Exception as exc:
state["status"] = "failed"
emit(state, "tool.failed", {
"action_id": action["action_id"],
"error": str(exc),
})
raise
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--approve", action="store_true")
args = parser.parse_args()
run(args.approve)
运行方式:
python harness.py
python harness.py --approve
cat run-state.json
这个示例仍是最小实现。接入真实工具时,还应补充参数校验、超时、重试策略和幂等键。尤其是支付、发信、创建工单等副作用操作,重试不能简单地再次执行;Harness 应先查询外部系统,确认相同 action_id 是否已经成功。
UI 边界决定控制权在哪里
一个成熟的 Agent UI 不只是展示 token 流。它需要把 Harness 暴露的状态映射成明确动作:
running:展示当前步骤,并允许暂停。waiting_approval:展示工具、关键参数和影响范围,提供批准与拒绝操作。failed:展示可公开的错误信息,并区分“重试当前工具”和“重新规划”。paused:保留运行上下文,允许用户修改目标后继续。completed:展示经过工具确认的结果和产物,而不是只展示模型总结。
前端也不应直接修改完整状态对象。更稳妥的 API 是提交命令,例如 POST /runs/{id}/approve、POST /runs/{id}/pause 和 POST /runs/{id}/resume,由后端验证当前状态是否允许该转换。这样可以避免客户端把 failed 任意改成 completed。
实时更新可以使用 Server-Sent Events 或 WebSocket,但事件流只是通知通道,数据库中的状态快照和事件记录才是恢复依据。客户端断线重连后,应先读取最新快照,再从最后一个事件序号继续订阅。
落地时先守住五条边界
从 ReAct 原型演进到 Agent Harness,不必一次建设复杂平台。可以按风险逐步推进:
- 先定义状态机和 Schema:明确允许的状态与转换,并给 Schema 设置版本。
- 区分提议与事实:模型只提出动作,Harness 执行工具并记录结果。
- 给副作用加审批和幂等性:高风险操作必须有显式策略,不能只靠提示词约束。
- 保存可恢复的检查点:至少持久化运行状态、待执行动作和事件序号。
- 让 UI 消费结构化状态:自然语言用于解释,按钮可用性和流程控制来自后端状态。
Harness 的价值不在于让 Agent 显得更自主,而在于限制自主行为的范围,并把执行过程变成产品可以管理的对象。模型能力会变化,工具也会持续增加;只要状态契约、运行事实和 UI 边界保持清晰,Agent 才能从一次性的演示循环演进为可交互、可恢复、可控制、可追溯的系统。