从“能跑”到“可运营”:Agent Harness 工程化建设指南

一个 Agent 能调用模型、执行工具并输出答案,只能说明 Demo 跑通了。真正进入生产环境后,我们需要面对上下文膨胀、任务中断、进程重启、重复执行、多实例竞争、成本失控和故障难以定位。解决这些问题的,不是继续堆 Prompt,而是在 Agent 外建立一层可靠的 Harness。

引言:Agent 为什么不能只是一段推理代码

很多 Agent 项目最初都有相似的结构:收到 HTTP 请求,创建一个可取消的 context.Context,调用模型或 ReAct Agent,然后把 token 通过 SSE 返回给浏览器。

go 复制代码
func HandleChat(ctx context.Context, input string) <-chan Event {
    runCtx, cancel := context.WithCancel(ctx)
    runningTasks.Store(conversationID, cancel)

    return agent.Run(runCtx, input)
}

这段代码很适合验证想法,但它把几件本应分离的事情绑在了一起:

  • HTTP 连接的生命周期;
  • Agent 执行的生命周期;
  • 用户看到的流式输出;
  • 任务状态与取消控制;
  • 上下文、记忆和执行历史。

只要系统开始承载真实用户,问题就会接连出现:

  1. 浏览器断开后,Agent 是否应该停止?
  2. 用户点击"停止",是永久取消,还是稍后可以继续?
  3. 服务重启后,执行到一半的搜索任务如何恢复?
  4. 多个实例部署后,/stop 请求如何找到真正执行任务的进程?
  5. Worker 失联后被其他实例接管,旧 Worker 恢复时会不会重复写结果?
  6. 对话越来越长时,哪些历史应该进入模型上下文?
  7. Agent 为什么做出某个决定,花了多少 token,在哪一步失败?

这些问题共同指向一个结论:Agent 不应该直接拥有生产运行时。

我们需要在 Agent 外建立一层 Harness。Agent 负责推理,Harness 负责让推理过程可靠地运行。


一、什么是 Agent Harness

"Harness"原本有"线束"和"控制装置"的含义。在 Agent 工程里,它可以理解为包裹 Agent 的运行时外壳:向内为模型和工具提供稳定执行环境,向外为 API、用户和运维系统提供一致的生命周期协议。

一个企业级 Agent Harness 至少应负责:

  • Run、Attempt、Step 的生命周期;
  • 上下文装配和 token 预算;
  • 短期工作记忆与长期用户画像;
  • 暂停、继续、取消和超时;
  • Checkpoint 与故障恢复;
  • 重试、幂等和并发控制;
  • 消息派发和多实例接管;
  • 事件流、日志、指标、Trace 和审计;
  • 成本、配额、测试与灰度发布。

这里最重要的设计原则是职责分离:

Agent 负责:

  • ReAct 或其他推理循环;
  • 选择模型和工具;
  • 产生流式输出;
  • 保存与恢复框架内部状态。

Harness 负责:

  • 任务现在是什么状态;
  • 哪个 Worker 有权执行;
  • 用户要求暂停还是永久取消;
  • 何时重试,最多重试多少次;
  • 上下文里放什么,为什么被裁剪;
  • 执行失败后从哪里恢复;
  • 整个过程如何被观测、审计和治理。

总体架构可以抽象成下面这样:
#mermaid-svg-OVD6x4e4sOcYty4u{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-OVD6x4e4sOcYty4u .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-OVD6x4e4sOcYty4u .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-OVD6x4e4sOcYty4u .error-icon{fill:#552222;}#mermaid-svg-OVD6x4e4sOcYty4u .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-OVD6x4e4sOcYty4u .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-OVD6x4e4sOcYty4u .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-OVD6x4e4sOcYty4u .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-OVD6x4e4sOcYty4u .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-OVD6x4e4sOcYty4u .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-OVD6x4e4sOcYty4u .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-OVD6x4e4sOcYty4u .marker{fill:#333333;stroke:#333333;}#mermaid-svg-OVD6x4e4sOcYty4u .marker.cross{stroke:#333333;}#mermaid-svg-OVD6x4e4sOcYty4u svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-OVD6x4e4sOcYty4u p{margin:0;}#mermaid-svg-OVD6x4e4sOcYty4u .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-OVD6x4e4sOcYty4u .cluster-label text{fill:#333;}#mermaid-svg-OVD6x4e4sOcYty4u .cluster-label span{color:#333;}#mermaid-svg-OVD6x4e4sOcYty4u .cluster-label span p{background-color:transparent;}#mermaid-svg-OVD6x4e4sOcYty4u .label text,#mermaid-svg-OVD6x4e4sOcYty4u span{fill:#333;color:#333;}#mermaid-svg-OVD6x4e4sOcYty4u .node rect,#mermaid-svg-OVD6x4e4sOcYty4u .node circle,#mermaid-svg-OVD6x4e4sOcYty4u .node ellipse,#mermaid-svg-OVD6x4e4sOcYty4u .node polygon,#mermaid-svg-OVD6x4e4sOcYty4u .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-OVD6x4e4sOcYty4u .rough-node .label text,#mermaid-svg-OVD6x4e4sOcYty4u .node .label text,#mermaid-svg-OVD6x4e4sOcYty4u .image-shape .label,#mermaid-svg-OVD6x4e4sOcYty4u .icon-shape .label{text-anchor:middle;}#mermaid-svg-OVD6x4e4sOcYty4u .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-OVD6x4e4sOcYty4u .rough-node .label,#mermaid-svg-OVD6x4e4sOcYty4u .node .label,#mermaid-svg-OVD6x4e4sOcYty4u .image-shape .label,#mermaid-svg-OVD6x4e4sOcYty4u .icon-shape .label{text-align:center;}#mermaid-svg-OVD6x4e4sOcYty4u .node.clickable{cursor:pointer;}#mermaid-svg-OVD6x4e4sOcYty4u .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-OVD6x4e4sOcYty4u .arrowheadPath{fill:#333333;}#mermaid-svg-OVD6x4e4sOcYty4u .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-OVD6x4e4sOcYty4u .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-OVD6x4e4sOcYty4u .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-OVD6x4e4sOcYty4u .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-OVD6x4e4sOcYty4u .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-OVD6x4e4sOcYty4u .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-OVD6x4e4sOcYty4u .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-OVD6x4e4sOcYty4u .cluster text{fill:#333;}#mermaid-svg-OVD6x4e4sOcYty4u .cluster span{color:#333;}#mermaid-svg-OVD6x4e4sOcYty4u div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-OVD6x4e4sOcYty4u .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-OVD6x4e4sOcYty4u rect.text{fill:none;stroke-width:0;}#mermaid-svg-OVD6x4e4sOcYty4u .icon-shape,#mermaid-svg-OVD6x4e4sOcYty4u .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-OVD6x4e4sOcYty4u .icon-shape p,#mermaid-svg-OVD6x4e4sOcYty4u .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-OVD6x4e4sOcYty4u .icon-shape .label rect,#mermaid-svg-OVD6x4e4sOcYty4u .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-OVD6x4e4sOcYty4u .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-OVD6x4e4sOcYty4u .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-OVD6x4e4sOcYty4u :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} SSE subscribe / replay
Web / API Client
Stateless Agent API
Run Service + State Machine
MySQL: Run / Outbox / Audit
Outbox Relay
Message Queue
Agent Worker
Lease + Fencing
Redis
Run Executor
Agent Runtime
Model
Tools / Sub Agents
Checkpoint Store
Object Storage
Run Events
OpenTelemetry

这不是简单地多加几个中间件,而是把"执行一次请求"重构为"管理一个持久化 Run"。


二、第一步:让 Run 成为一等公民

2.1 Conversation 不是 Run

一个 Conversation 可以有多轮消息,而每次消息处理都可能形成一个独立 Run。一次 Run 还可能因为暂停、崩溃和恢复产生多个 Attempt。

因此至少要区分三个概念:

  • Run:一次用户意图的完整生命周期;
  • Attempt:某个 Worker 对该 Run 的一次执行或恢复尝试;
  • Step:Run 内可识别的 Agent、子 Agent 或关键工具边界。

如果直接用 conversation_id 作为运行标识,会出现很多歧义:同一会话中的两个请求无法区分,暂停命令可能命中错误任务,Trace 和成本也无法正确归属。

2.2 API 创建 Run,而不是直接执行 Agent

推荐的 API 语义是:

http 复制代码
POST /conversations/{conversation_id}/runs
Idempotency-Key: client-generated-key

HTTP/1.1 202 Accepted
{
  "run_id": "0190...",
  "state": "queued",
  "events_url": "/agent/runs/0190.../events"
}

创建请求只负责:

  1. 校验用户与资源权限;
  2. 在 MySQL 中创建 Run;
  3. 同一事务写入 Outbox 事件;
  4. 返回稳定的 run_id

真正的 Agent 执行由 Worker 异步完成。这样,API 实例可以随时扩容、重启或滚动发布,而不会直接拥有任务。

为了兼容已有 SSE 接口,可以保留一个适配层:内部先创建 Run,再立即订阅该 Run 的事件流。前端体验不必一次性重写,但底层生命周期已经完成解耦。

2.3 状态机必须显式

很多系统只有 running/success/error 三种状态,结果是暂停、超时、重试等待、Worker 丢失和永久失败全部被塞进 error。这样的状态无法驱动可靠恢复。

一个更完整的状态机如下:
#mermaid-svg-FpRXiWeBR4Hl35rW{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-FpRXiWeBR4Hl35rW .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-FpRXiWeBR4Hl35rW .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-FpRXiWeBR4Hl35rW .error-icon{fill:#552222;}#mermaid-svg-FpRXiWeBR4Hl35rW .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-FpRXiWeBR4Hl35rW .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-FpRXiWeBR4Hl35rW .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-FpRXiWeBR4Hl35rW .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-FpRXiWeBR4Hl35rW .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-FpRXiWeBR4Hl35rW .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-FpRXiWeBR4Hl35rW .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-FpRXiWeBR4Hl35rW .marker{fill:#333333;stroke:#333333;}#mermaid-svg-FpRXiWeBR4Hl35rW .marker.cross{stroke:#333333;}#mermaid-svg-FpRXiWeBR4Hl35rW svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-FpRXiWeBR4Hl35rW p{margin:0;}#mermaid-svg-FpRXiWeBR4Hl35rW defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-FpRXiWeBR4Hl35rW g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-FpRXiWeBR4Hl35rW g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-FpRXiWeBR4Hl35rW g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-FpRXiWeBR4Hl35rW g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-FpRXiWeBR4Hl35rW g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-FpRXiWeBR4Hl35rW .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-FpRXiWeBR4Hl35rW .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-FpRXiWeBR4Hl35rW .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-FpRXiWeBR4Hl35rW .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-FpRXiWeBR4Hl35rW .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-FpRXiWeBR4Hl35rW .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-FpRXiWeBR4Hl35rW .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-FpRXiWeBR4Hl35rW .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FpRXiWeBR4Hl35rW .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-FpRXiWeBR4Hl35rW .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FpRXiWeBR4Hl35rW .edgeLabel .label text{fill:#333;}#mermaid-svg-FpRXiWeBR4Hl35rW .label div .edgeLabel{color:#333;}#mermaid-svg-FpRXiWeBR4Hl35rW .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-FpRXiWeBR4Hl35rW .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-FpRXiWeBR4Hl35rW .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-FpRXiWeBR4Hl35rW .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-FpRXiWeBR4Hl35rW .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-FpRXiWeBR4Hl35rW .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-FpRXiWeBR4Hl35rW .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-FpRXiWeBR4Hl35rW #statediagram-barbEnd{fill:#333333;}#mermaid-svg-FpRXiWeBR4Hl35rW .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-FpRXiWeBR4Hl35rW .cluster-label,#mermaid-svg-FpRXiWeBR4Hl35rW .nodeLabel{color:#131300;}#mermaid-svg-FpRXiWeBR4Hl35rW .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-FpRXiWeBR4Hl35rW .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-FpRXiWeBR4Hl35rW .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-FpRXiWeBR4Hl35rW .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-FpRXiWeBR4Hl35rW .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-FpRXiWeBR4Hl35rW .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-FpRXiWeBR4Hl35rW .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-FpRXiWeBR4Hl35rW .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-FpRXiWeBR4Hl35rW .note-edge{stroke-dasharray:5;}#mermaid-svg-FpRXiWeBR4Hl35rW .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-FpRXiWeBR4Hl35rW .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-FpRXiWeBR4Hl35rW .statediagram-note text{fill:black;}#mermaid-svg-FpRXiWeBR4Hl35rW .statediagram-note .nodeLabel{color:black;}#mermaid-svg-FpRXiWeBR4Hl35rW .statediagram .edgeLabel{color:red;}#mermaid-svg-FpRXiWeBR4Hl35rW #dependencyStart,#mermaid-svg-FpRXiWeBR4Hl35rW #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-FpRXiWeBR4Hl35rW .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-FpRXiWeBR4Hl35rW :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} worker claim
executor ready
output persisted
user pause
safe-point requested
checkpoint verified
pause before execution
user resume
redispatch
transient error
lease expired
lease expired
recoverable
backoff elapsed
retry budget exhausted
permanent error
explicit abandon
queued
claimed
running
succeeded
pause_requested
pausing
paused
resume_requested
retry_wait
recovering
suspended
failed
cancel_requested
cancelling
cancelled

API 发起的命令使用 state + state_version 做乐观锁。Worker 写状态、步骤、事件和 Checkpoint 时,还必须校验 fencing token。任何条件更新失败都意味着状态已被并发操作改变,不能继续凭本地内存做决定。


三、上下文管理:不是"把更多历史塞进去"

Agent 工程中最容易被低估的部分是上下文管理。

常见的第一版实现是保留最近 N 轮消息:

text 复制代码
系统提示词 + 最近 10 轮 + 当前问题

稍好一些的版本会加入滚动摘要:

text 复制代码
系统提示词 + 历史摘要 + 最近 10 轮 + 当前问题

这两种方案都能工作,但它们按时间远近决定信息是否重要。用户很早以前提出的关键约束可能被丢弃,最近的闲聊反而长期占用 token。摘要反复更新后还会发生事实漂移。

3.1 把上下文看成受预算约束的装配过程

更稳定的方式是建立 ContextAssembler。每次模型调用前,它根据模型能力、当前 Run 状态和候选信息生成一个 ContextEnvelope

输入预算可以表示为:

text 复制代码
input_budget = context_window
             - reserved_output
             - serialized_tool_schema
             - safety_margin

上下文内容分为两类。

硬保留层:

  • 系统与安全策略;
  • 工具 Schema;
  • 当前任务目标与工作状态;
  • 当前用户请求。

软预算层:

  • 与当前任务相关的用户画像;
  • 版本化会话摘要;
  • 相关旧历史;
  • 最近几轮对话;
  • 搜索或工具证据。

#mermaid-svg-bOgHH52r6XtYybnI{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-bOgHH52r6XtYybnI .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-bOgHH52r6XtYybnI .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-bOgHH52r6XtYybnI .error-icon{fill:#552222;}#mermaid-svg-bOgHH52r6XtYybnI .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-bOgHH52r6XtYybnI .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-bOgHH52r6XtYybnI .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-bOgHH52r6XtYybnI .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-bOgHH52r6XtYybnI .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-bOgHH52r6XtYybnI .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-bOgHH52r6XtYybnI .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-bOgHH52r6XtYybnI .marker{fill:#333333;stroke:#333333;}#mermaid-svg-bOgHH52r6XtYybnI .marker.cross{stroke:#333333;}#mermaid-svg-bOgHH52r6XtYybnI svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-bOgHH52r6XtYybnI p{margin:0;}#mermaid-svg-bOgHH52r6XtYybnI .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-bOgHH52r6XtYybnI .cluster-label text{fill:#333;}#mermaid-svg-bOgHH52r6XtYybnI .cluster-label span{color:#333;}#mermaid-svg-bOgHH52r6XtYybnI .cluster-label span p{background-color:transparent;}#mermaid-svg-bOgHH52r6XtYybnI .label text,#mermaid-svg-bOgHH52r6XtYybnI span{fill:#333;color:#333;}#mermaid-svg-bOgHH52r6XtYybnI .node rect,#mermaid-svg-bOgHH52r6XtYybnI .node circle,#mermaid-svg-bOgHH52r6XtYybnI .node ellipse,#mermaid-svg-bOgHH52r6XtYybnI .node polygon,#mermaid-svg-bOgHH52r6XtYybnI .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-bOgHH52r6XtYybnI .rough-node .label text,#mermaid-svg-bOgHH52r6XtYybnI .node .label text,#mermaid-svg-bOgHH52r6XtYybnI .image-shape .label,#mermaid-svg-bOgHH52r6XtYybnI .icon-shape .label{text-anchor:middle;}#mermaid-svg-bOgHH52r6XtYybnI .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-bOgHH52r6XtYybnI .rough-node .label,#mermaid-svg-bOgHH52r6XtYybnI .node .label,#mermaid-svg-bOgHH52r6XtYybnI .image-shape .label,#mermaid-svg-bOgHH52r6XtYybnI .icon-shape .label{text-align:center;}#mermaid-svg-bOgHH52r6XtYybnI .node.clickable{cursor:pointer;}#mermaid-svg-bOgHH52r6XtYybnI .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-bOgHH52r6XtYybnI .arrowheadPath{fill:#333333;}#mermaid-svg-bOgHH52r6XtYybnI .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-bOgHH52r6XtYybnI .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-bOgHH52r6XtYybnI .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-bOgHH52r6XtYybnI .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-bOgHH52r6XtYybnI .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-bOgHH52r6XtYybnI .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-bOgHH52r6XtYybnI .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-bOgHH52r6XtYybnI .cluster text{fill:#333;}#mermaid-svg-bOgHH52r6XtYybnI .cluster span{color:#333;}#mermaid-svg-bOgHH52r6XtYybnI div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-bOgHH52r6XtYybnI .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-bOgHH52r6XtYybnI rect.text{fill:none;stroke-width:0;}#mermaid-svg-bOgHH52r6XtYybnI .icon-shape,#mermaid-svg-bOgHH52r6XtYybnI .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-bOgHH52r6XtYybnI .icon-shape p,#mermaid-svg-bOgHH52r6XtYybnI .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-bOgHH52r6XtYybnI .icon-shape .label rect,#mermaid-svg-bOgHH52r6XtYybnI .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-bOgHH52r6XtYybnI .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-bOgHH52r6XtYybnI .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-bOgHH52r6XtYybnI :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} System + Safety
Required Layers
Tool Schemas
Run Working State
Current Request
Relevant Profile
Optional Candidates
Versioned Summary
Relevant + Recent History
Tool Evidence
ContextAssembler
Score + Token Cost + Risk
ContextEnvelope + Manifest

候选内容可以使用类似的评分:

text 复制代码
score = relevance
      + importance
      + recency
      + task_continuity
      - token_cost
      - trust_risk

这里的关键不是找到一条完美公式,而是让选择过程可解释、可测试、可观测。

3.2 短期工作记忆必须结构化

对话摘要负责回答"之前聊过什么",任务工作记忆负责回答"现在做到哪一步"。两者不能混在一起。

json 复制代码
{
  "goal": "完成用户问题的回答",
  "current_step": "searching",
  "constraints": [
    "使用用户选择的资料",
    "网络信息必须带来源"
  ],
  "completed_steps": ["context_assembled"],
  "pending_steps": ["search", "final_answer"],
  "artifacts": [
    {"id": "artifact-id", "kind": "search_results"}
  ]
}

工作记忆是 Run 状态的一部分,可以被持久化、审计和恢复。它不保存模型的隐藏思维链,只保存用户输入、工具结果和系统明确作出的结构化决定。

3.3 摘要必须有版本边界

滚动摘要至少要记录:

  • through_message_id:摘要覆盖到哪条消息;
  • version:用于乐观锁;
  • 生成模型与 Prompt 版本;
  • token 数和生成时间。

异步摘要任务更新时必须携带 expected version。否则较慢的旧任务可能在新任务之后完成,然后覆盖更完整的摘要。

3.4 工具产物不要永久塞进 Prompt

网页正文、完整搜索结果和大型文档应该保存为 Artifact。Prompt 中只放摘要、必要证据和 Artifact 引用,需要时再读取原文。

这同时解决三个问题:

  • 控制 token;
  • 保留可审计证据;
  • 降低不可信网页文本对主 Prompt 的污染。

来自网页和工具的内容应被标记为 untrusted evidence,不能覆盖系统规则或用户已确认约束。


四、长期记忆:优先建设用户画像,而不是无限聊天仓库

长期记忆很容易被理解为"把所有聊天消息向量化,然后每次检索"。这种方式可以召回旧内容,却也会把临时情绪、错误推断、过期事实和敏感信息长期带回上下文。

更稳妥的第一步,是建设结构化用户画像。

适合长期保存的内容包括:

  • 使用语言;
  • 回答风格;
  • 专业背景和熟悉程度;
  • 常用输出格式;
  • 稳定工作方式和明确偏好。
go 复制代码
type ProfileFact struct {
    UserID          uint
    Category        string
    Key             string
    NormalizedValue string
    Confidence      float64
    SourceMessageID uint
    Version         int
    Status          string
    UpdatedAt       time.Time
}

4.1 混合写入策略

  • 用户明确说"记住我......"时直接写入;
  • 模型发现稳定偏好时生成候选;
  • 候选必须属于白名单类别并达到置信度阈值;
  • 冲突内容创建新版本,不静默覆盖;
  • API Key、密码、Cookie、访问令牌和私钥永不进入长期记忆。

画像通常规模很小,首期没有必要立刻引入向量数据库。根据任务类型选择相关类别,再按置信度和更新时间排序即可。注入 Prompt 时还要设置比例上限,例如不超过输入预算的 5%。

用户必须能够查看、修改、单条删除、全部清空和关闭记忆。关闭后既不提取新画像,也不把已有画像注入上下文。

长期记忆的价值不在于"记得越多",而在于只记稳定、相关、可追溯、可撤销的信息


五、Checkpoint:把中断恢复建立在安全点上

"任务恢复"有三个不同等级:

  1. 从原始输入重新执行;
  2. 从最近安全点继续;
  3. 跨小时、人工审批和补偿事务的长工作流恢复。

多数通用 Agent Harness 的合理起点是第二级:模型或工具调用结束后保存框架内部状态,恢复时从该安全点继续。

以支持 Checkpoint 的 Agent 框架为例,运行时通常需要以下能力:

go 复制代码
type CheckPointStore interface {
    Get(ctx context.Context, checkpointID string) ([]byte, bool, error)
    Set(ctx context.Context, checkpointID string, data []byte) error
}

type CheckPointDeleter interface {
    Delete(ctx context.Context, checkpointID string) error
}

运行时为每个 Run 配置稳定的 checkpoint key:

go 复制代码
runner := adk.NewRunner(ctx, adk.RunnerConfig{
    Agent:           mainAgent,
    EnableStreaming: true,
    CheckPointStore: checkpointStore,
})

cancelOpt, cancelAgent := adk.WithCancel()

iter := runner.Run(
    runCtx,
    messages,
    cancelOpt,
    adk.WithCheckPointID(checkpointKey),
)

5.1 正确的暂停流程

用户点击"停止"后,不能立即把 Run 标成 paused。正确流程是:

  1. API 持久化 pause_requested
  2. 当前 Worker 收到暂停意图;
  3. Worker 请求模型后或工具后安全点取消;
  4. 继续消费 Agent iterator;
  5. 框架生成取消事件并保存 checkpoint;
  6. Harness 校验 checkpoint 是否存在、校验和是否正确;
  7. 校验通过后才写入 paused
go 复制代码
handle, accepted := cancelAgent(
    adk.WithAgentCancelMode(
        adk.CancelAfterChatModel | adk.CancelAfterToolCalls,
    ),
    adk.WithRecursive(),
    adk.WithAgentCancelTimeout(cancelTimeout),
)

if accepted {
    if err := handle.Wait(); err != nil {
        // classify timeout or execution-ended outcome
    }
}

一个常见错误是调用 cancel 后立刻停止读取 iterator。某些框架只有在事件流被持续消费时,才能完成取消传播和 checkpoint 落盘。因此 Harness 必须等待取消结果,而不是只发一个信号就返回。

5.2 Resume 必须重建兼容环境

Checkpoint 并不是脱离代码版本独立存在的魔法快照。恢复前至少要校验:

  • Agent 定义版本;
  • Prompt 版本;
  • 工具 Schema 版本;
  • 模型 Provider 与配置 Hash;
  • Agent 框架和 Checkpoint 格式版本。

版本完全一致可以直接 Resume;明确声明向后兼容的版本可以通过兼容矩阵放行;其他情况应进入 suspended,由用户选择从原请求重新运行。盲目加载旧 Checkpoint,往往比安全失败更危险。

5.3 Checkpoint Store 不应只是一个覆盖写的 Blob

生产环境应保存版本化 Checkpoint:逻辑 key 对应多个不可变版本,写入新版本后再原子切换 current pointer。记录至少包括:

  • Blob 或对象存储 URI;
  • 字节数和 SHA-256;
  • Run、Attempt 和 fencing token;
  • Agent、Prompt、工具和模型版本;
  • 创建与过期时间。

小 Checkpoint 可以放 MySQL,大 Checkpoint 放对象存储,数据库只保存元数据和指针。


六、断线、暂停、取消和关闭必须是四种语义

很多系统把所有停止行为都实现成 context.CancelFunc。这会让产品语义变得模糊:用户只是关闭页面,任务却永久消失;用户想暂停,后台工具却继续执行。

建议明确区分:

事件 语义 Run 行为
SSE 断线 Detach Run 继续执行
"停止生成" Pause 安全点保存 Checkpoint,可继续
"永久放弃" Cancel 进入终态,不可继续
Worker 下线 Drain 尝试安全暂停,否则由其他实例接管

6.1 不要让 HTTP Context 成为 Run Context

推荐的 Context 层级是:

text 复制代码
requestCtx   只服务 API 校验和响应
runCtx       由 Worker 创建,属于 Run 生命周期
attemptCtx   属于一次执行尝试
stepCtx      属于模型、子 Agent 或工具步骤

请求断开只关闭订阅,不触发 runCtx。业务暂停通过框架安全点协议完成;进程硬退出时才使用普通 Context 取消做资源清理。

同样要谨慎使用 context.WithoutCancel。它适合明确要脱离父请求的短后台操作,但如果被用来启动 SearchAgent 或生成任务,就可能绕过用户暂停、Run 超时和优雅下线。更好的方式不是"去掉取消",而是让任务继承正确的 runCtx


七、多实例接管:Lease 解决活性,Fencing 解决正确性

当系统只有一个进程时,用内存保存 run_id -> cancelFunc 似乎足够。部署多个实例后,这个方案立即失效:暂停请求可能到达 API-B,而任务实际运行在 Worker-A。

更重要的是,即便使用 Redis 锁,也不能自动获得正确的故障接管。

7.1 为什么只有 Lease 不够

假设 Worker A 获得 20 秒租约,然后发生长时间网络暂停。租约过期后 Worker B 接管任务。此时 A 恢复,它仍然可能继续把旧结果写入数据库。

因此需要同时使用:

  • Lease:判断 Worker 是否还活着,以及何时可以尝试接管;
  • Fencing Token:判断一个 Worker 是否仍有资格写入。

Worker B Redis MySQL Worker A Worker B Redis MySQL Worker A #mermaid-svg-uhNfV6IdOagMfJQA{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-uhNfV6IdOagMfJQA .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-uhNfV6IdOagMfJQA .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-uhNfV6IdOagMfJQA .error-icon{fill:#552222;}#mermaid-svg-uhNfV6IdOagMfJQA .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-uhNfV6IdOagMfJQA .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-uhNfV6IdOagMfJQA .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-uhNfV6IdOagMfJQA .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-uhNfV6IdOagMfJQA .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-uhNfV6IdOagMfJQA .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-uhNfV6IdOagMfJQA .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-uhNfV6IdOagMfJQA .marker{fill:#333333;stroke:#333333;}#mermaid-svg-uhNfV6IdOagMfJQA .marker.cross{stroke:#333333;}#mermaid-svg-uhNfV6IdOagMfJQA svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-uhNfV6IdOagMfJQA p{margin:0;}#mermaid-svg-uhNfV6IdOagMfJQA .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-uhNfV6IdOagMfJQA text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-uhNfV6IdOagMfJQA .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-uhNfV6IdOagMfJQA .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-uhNfV6IdOagMfJQA .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-uhNfV6IdOagMfJQA .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-uhNfV6IdOagMfJQA #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-uhNfV6IdOagMfJQA .sequenceNumber{fill:white;}#mermaid-svg-uhNfV6IdOagMfJQA #sequencenumber{fill:#333;}#mermaid-svg-uhNfV6IdOagMfJQA #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-uhNfV6IdOagMfJQA .messageText{fill:#333;stroke:none;}#mermaid-svg-uhNfV6IdOagMfJQA .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-uhNfV6IdOagMfJQA .labelText,#mermaid-svg-uhNfV6IdOagMfJQA .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-uhNfV6IdOagMfJQA .loopText,#mermaid-svg-uhNfV6IdOagMfJQA .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-uhNfV6IdOagMfJQA .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-uhNfV6IdOagMfJQA .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-uhNfV6IdOagMfJQA .noteText,#mermaid-svg-uhNfV6IdOagMfJQA .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-uhNfV6IdOagMfJQA .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-uhNfV6IdOagMfJQA .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-uhNfV6IdOagMfJQA .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-uhNfV6IdOagMfJQA .actorPopupMenu{position:absolute;}#mermaid-svg-uhNfV6IdOagMfJQA .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-uhNfV6IdOagMfJQA .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-uhNfV6IdOagMfJQA .actor-man circle,#mermaid-svg-uhNfV6IdOagMfJQA line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-uhNfV6IdOagMfJQA :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Claim run, fencing=41SET lease(run,41) with TTLNetwork pauseLease expired, CAS claim, fencing=42SET lease(run,42) with TTLResume and persist using token 42Late write using token 41Reject stale writer

所有来自 Worker 的 Run、Step、Event 和 Checkpoint pointer 写入,都需要类似的条件:

sql 复制代码
UPDATE agent_runs
SET state = ?, state_version = state_version + 1
WHERE id = ?
  AND owner_worker_id = ?
  AND fencing_token = ?
  AND state_version = ?;

Redis 提供低延迟租约和心跳,MySQL 保存 owner、lease expiry 和单调递增的 fencing token。Redis 丢失不应让任务事实消失。


八、MQ 负责传输,数据库负责事实

引入消息队列后,另一个常见误区是把 MQ 当作任务状态源。消息被 ACK、过期或重建队列后,系统就无法回答"这个 Run 到底处于什么状态"。

更稳妥的职责划分是:

  • MySQL 决定任务是什么状态;
  • MQ 决定任务由谁尽快处理;
  • Redis 决定 Worker 当前是否仍持有租约;
  • Checkpoint 决定恢复时从哪个内部安全点继续。

8.1 Transactional Outbox

数据库状态和 MQ 发布不能依赖普通"双写":如果数据库提交成功、消息发布失败,Run 会永远停在队列中;如果先发布消息再回滚数据库,Worker 会收到不存在的任务。

Outbox 模式把两件事放在一个事务中:
#mermaid-svg-nwQgQCDHJnRhMLBl{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-nwQgQCDHJnRhMLBl .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-nwQgQCDHJnRhMLBl .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-nwQgQCDHJnRhMLBl .error-icon{fill:#552222;}#mermaid-svg-nwQgQCDHJnRhMLBl .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-nwQgQCDHJnRhMLBl .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-nwQgQCDHJnRhMLBl .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-nwQgQCDHJnRhMLBl .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-nwQgQCDHJnRhMLBl .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-nwQgQCDHJnRhMLBl .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-nwQgQCDHJnRhMLBl .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-nwQgQCDHJnRhMLBl .marker{fill:#333333;stroke:#333333;}#mermaid-svg-nwQgQCDHJnRhMLBl .marker.cross{stroke:#333333;}#mermaid-svg-nwQgQCDHJnRhMLBl svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-nwQgQCDHJnRhMLBl p{margin:0;}#mermaid-svg-nwQgQCDHJnRhMLBl .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-nwQgQCDHJnRhMLBl .cluster-label text{fill:#333;}#mermaid-svg-nwQgQCDHJnRhMLBl .cluster-label span{color:#333;}#mermaid-svg-nwQgQCDHJnRhMLBl .cluster-label span p{background-color:transparent;}#mermaid-svg-nwQgQCDHJnRhMLBl .label text,#mermaid-svg-nwQgQCDHJnRhMLBl span{fill:#333;color:#333;}#mermaid-svg-nwQgQCDHJnRhMLBl .node rect,#mermaid-svg-nwQgQCDHJnRhMLBl .node circle,#mermaid-svg-nwQgQCDHJnRhMLBl .node ellipse,#mermaid-svg-nwQgQCDHJnRhMLBl .node polygon,#mermaid-svg-nwQgQCDHJnRhMLBl .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-nwQgQCDHJnRhMLBl .rough-node .label text,#mermaid-svg-nwQgQCDHJnRhMLBl .node .label text,#mermaid-svg-nwQgQCDHJnRhMLBl .image-shape .label,#mermaid-svg-nwQgQCDHJnRhMLBl .icon-shape .label{text-anchor:middle;}#mermaid-svg-nwQgQCDHJnRhMLBl .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-nwQgQCDHJnRhMLBl .rough-node .label,#mermaid-svg-nwQgQCDHJnRhMLBl .node .label,#mermaid-svg-nwQgQCDHJnRhMLBl .image-shape .label,#mermaid-svg-nwQgQCDHJnRhMLBl .icon-shape .label{text-align:center;}#mermaid-svg-nwQgQCDHJnRhMLBl .node.clickable{cursor:pointer;}#mermaid-svg-nwQgQCDHJnRhMLBl .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-nwQgQCDHJnRhMLBl .arrowheadPath{fill:#333333;}#mermaid-svg-nwQgQCDHJnRhMLBl .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-nwQgQCDHJnRhMLBl .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-nwQgQCDHJnRhMLBl .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-nwQgQCDHJnRhMLBl .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-nwQgQCDHJnRhMLBl .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-nwQgQCDHJnRhMLBl .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-nwQgQCDHJnRhMLBl .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-nwQgQCDHJnRhMLBl .cluster text{fill:#333;}#mermaid-svg-nwQgQCDHJnRhMLBl .cluster span{color:#333;}#mermaid-svg-nwQgQCDHJnRhMLBl div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-nwQgQCDHJnRhMLBl .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-nwQgQCDHJnRhMLBl rect.text{fill:none;stroke-width:0;}#mermaid-svg-nwQgQCDHJnRhMLBl .icon-shape,#mermaid-svg-nwQgQCDHJnRhMLBl .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-nwQgQCDHJnRhMLBl .icon-shape p,#mermaid-svg-nwQgQCDHJnRhMLBl .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-nwQgQCDHJnRhMLBl .icon-shape .label rect,#mermaid-svg-nwQgQCDHJnRhMLBl .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-nwQgQCDHJnRhMLBl .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-nwQgQCDHJnRhMLBl .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-nwQgQCDHJnRhMLBl :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} MySQL Transaction
Update Run State
Insert Outbox Event
Outbox Relay
Publish stable event_id
Consumer
Consumer Inbox
Run CAS Claim
ACK

Relay 可能在发布成功、标记 Outbox 前崩溃,因此消息仍可能重复。消费者需要通过 event_id Inbox、Run 状态 CAS 和幂等命令去重。

8.2 "Exactly-once"通常是一个危险承诺

Harness 内的状态转换可以做到有效幂等,但外部搜索 API 往往不支持幂等键。

如果搜索调用已经成功,进程却在结果持久化前崩溃,恢复时可能再次调用供应商。此时更诚实的语义是:

text 复制代码
至少一次执行
+ 结果去重
+ 调用次数与费用预算
+ 重复外部调用指标

完成的 Search Step 应保存标准化 query hash、tool call ID 和结果 Artifact。恢复时优先复用持久化结果;只有找不到结果时才再次调用外部服务。


九、重试:需要统一预算,而不是每层各自"重试三次"

一个典型的重试风暴是:

  • HTTP 客户端重试 2 次;
  • Run 重试 3 次;
  • SearchAgent 重试 3 次;
  • 模型 SDK 再重试 2 次。

最终一次用户请求可能触发 36 次调用。

Harness 应集中管理重试预算:

  • 模型层只重试明确的瞬时错误;
  • SearchAgent Attempt 有独立上限;
  • Worker 崩溃恢复有独立上限;
  • 下层把实际重试次数上报给上层;
  • 同时限制总 wall time、token、搜索次数和费用。

错误也必须分类:

错误类别 例子 动作
transient 网络、429、可恢复 5xx 退避重试
worker_lost 租约过期、进程崩溃 Checkpoint 接管
resource 预算、配额、系统过载 suspended
permanent 参数、权限、配置错误 failed
user_pause 用户停止 paused,不消耗重试
checkpoint_invalid 损坏或版本不兼容 suspended 并告警

重试不是错误处理的默认答案。对认证失败、参数错误和预算超限反复重试,只会增加延迟和成本。


十、可观测性:从 Run ID 还原整条执行链

生产环境最痛苦的问题之一是:日志很多,却无法解释一次 Agent Run 发生了什么。

OpenTelemetry 应作为统一协议,把 Run、Agent、模型、工具、Checkpoint 和状态转换串成一棵 Trace 树:

text 复制代码
agent.run
├── context.assemble
├── main_agent.execute
│   ├── model.call
│   ├── search_agent.execute
│   │   ├── model.call
│   │   └── tool.web_search
│   └── model.call
├── checkpoint.save
└── run.transition

10.1 异步消息如何传播 Trace

HTTP 请求使用 W3C traceparent。Outbox 保存 Trace Context,发布到 MQ 时写入 Message Header。Worker 创建新的 Attempt Span,并通过 Span Link 关联原始请求和前一个 Attempt。

不要维持一个跨越数小时的父 Span。长时间异步任务更适合使用多个可结束的 Span 和 Link。

10.2 需要关注的指标

Run 指标:

  • 队列等待时间;
  • 各状态停留时间;
  • 成功、失败、暂停和恢复比例;
  • Worker 接管次数。

恢复指标:

  • Pause 命令接受与完成延迟;
  • Checkpoint 大小和读写耗时;
  • Resume 成功率;
  • Checkpoint 版本不兼容次数。

模型与上下文指标:

  • token、延迟、重试和费用;
  • 上下文预算利用率;
  • 各层候选、入选和裁剪 token;
  • 摘要和用户画像命中率。

基础设施指标:

  • MQ consumer lag、redelivery 和 DLQ;
  • Outbox backlog;
  • Lease heartbeat 和过期;
  • MySQL CAS 冲突与连接池。

Run ID 和 User ID 不应成为 Prometheus Label,否则会产生高基数灾难。它们适合进入 Trace 和结构化日志。指标 Label 应限制在 agent、provider、model family、state、error class 等有限枚举。

10.3 日志、Trace 和审计各有职责

  • 日志用于排障,可以按策略清理;
  • Trace用于还原调用链和性能;
  • 审计事件记录谁在何时创建、暂停、继续或取消了任务。

普通日志不应保存完整 Prompt、网页正文、API Key 或用户画像值。如果首期不做执行回放,只记录 Prompt Hash、token 数和 Context Manifest 已经足够覆盖大量问题。


十一、测试 Harness,而不只是测试"模型回答对不对"

Agent Harness 的核心价值是故障条件下仍保持正确。因此测试必须包含状态、并发和故障注入。

11.1 单元测试

  • 状态转换表和非法转换;
  • 错误分类和重试预算;
  • token 计算、评分和裁剪;
  • 摘要版本冲突;
  • 用户画像冲突、删除和总开关;
  • 幂等键规范化。

11.2 集成测试

  • MySQL Run/Attempt/Outbox 事务;
  • MQ 重复投递、NAK、MaxDeliver 和 DLQ;
  • Redis Lease、Heartbeat 和过期接管;
  • Checkpoint Store 的 Get/Set/Delete;
  • SSE Last-Event-ID 重放;
  • 对象存储中的大型 Checkpoint 与校验和。

11.3 必须做的故障注入

故障点 注入方式 期望结果
SSE 断线 流式中关闭连接 Run 继续,重连补发
模型调用中暂停 发送 Pause 命令 安全点后进入 paused
子 Agent 中暂停 递归安全取消 主子 Checkpoint 可恢复
Worker 硬崩溃 kill -9 或强停容器 租约过期后接管
旧 Worker 复活 暂停网络后恢复 旧 Token 写入被拒绝
MQ 重复投递 重发相同 event ID 只产生一个有效 Claim
Outbox Relay 崩溃 Publish 后、Mark 前终止 安全补发
Checkpoint 损坏 删除对象或修改 Hash 禁止 Resume,进入 suspended
版本不兼容 升级工具后恢复旧 Run 按兼容矩阵处理

还需要独立的 Agent 质量评测:历史约束召回率、摘要事实一致性、用户画像写入准确率、搜索结果完整性,以及 token、延迟和费用回归。

工程正确性和模型质量是两个维度,不能用一组测试互相替代。


十二、不要一步到位:推荐的七阶段路线

最终目标可以是多实例接管,但实施顺序应先证明单实例正确性,再引入分布式复杂度。
#mermaid-svg-2L4lDghQxdGWWcO7{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-2L4lDghQxdGWWcO7 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-2L4lDghQxdGWWcO7 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-2L4lDghQxdGWWcO7 .error-icon{fill:#552222;}#mermaid-svg-2L4lDghQxdGWWcO7 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-2L4lDghQxdGWWcO7 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-2L4lDghQxdGWWcO7 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-2L4lDghQxdGWWcO7 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-2L4lDghQxdGWWcO7 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-2L4lDghQxdGWWcO7 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-2L4lDghQxdGWWcO7 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-2L4lDghQxdGWWcO7 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-2L4lDghQxdGWWcO7 .marker.cross{stroke:#333333;}#mermaid-svg-2L4lDghQxdGWWcO7 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-2L4lDghQxdGWWcO7 p{margin:0;}#mermaid-svg-2L4lDghQxdGWWcO7 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-2L4lDghQxdGWWcO7 .cluster-label text{fill:#333;}#mermaid-svg-2L4lDghQxdGWWcO7 .cluster-label span{color:#333;}#mermaid-svg-2L4lDghQxdGWWcO7 .cluster-label span p{background-color:transparent;}#mermaid-svg-2L4lDghQxdGWWcO7 .label text,#mermaid-svg-2L4lDghQxdGWWcO7 span{fill:#333;color:#333;}#mermaid-svg-2L4lDghQxdGWWcO7 .node rect,#mermaid-svg-2L4lDghQxdGWWcO7 .node circle,#mermaid-svg-2L4lDghQxdGWWcO7 .node ellipse,#mermaid-svg-2L4lDghQxdGWWcO7 .node polygon,#mermaid-svg-2L4lDghQxdGWWcO7 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-2L4lDghQxdGWWcO7 .rough-node .label text,#mermaid-svg-2L4lDghQxdGWWcO7 .node .label text,#mermaid-svg-2L4lDghQxdGWWcO7 .image-shape .label,#mermaid-svg-2L4lDghQxdGWWcO7 .icon-shape .label{text-anchor:middle;}#mermaid-svg-2L4lDghQxdGWWcO7 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-2L4lDghQxdGWWcO7 .rough-node .label,#mermaid-svg-2L4lDghQxdGWWcO7 .node .label,#mermaid-svg-2L4lDghQxdGWWcO7 .image-shape .label,#mermaid-svg-2L4lDghQxdGWWcO7 .icon-shape .label{text-align:center;}#mermaid-svg-2L4lDghQxdGWWcO7 .node.clickable{cursor:pointer;}#mermaid-svg-2L4lDghQxdGWWcO7 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-2L4lDghQxdGWWcO7 .arrowheadPath{fill:#333333;}#mermaid-svg-2L4lDghQxdGWWcO7 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-2L4lDghQxdGWWcO7 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-2L4lDghQxdGWWcO7 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-2L4lDghQxdGWWcO7 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-2L4lDghQxdGWWcO7 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-2L4lDghQxdGWWcO7 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-2L4lDghQxdGWWcO7 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-2L4lDghQxdGWWcO7 .cluster text{fill:#333;}#mermaid-svg-2L4lDghQxdGWWcO7 .cluster span{color:#333;}#mermaid-svg-2L4lDghQxdGWWcO7 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-2L4lDghQxdGWWcO7 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-2L4lDghQxdGWWcO7 rect.text{fill:none;stroke-width:0;}#mermaid-svg-2L4lDghQxdGWWcO7 .icon-shape,#mermaid-svg-2L4lDghQxdGWWcO7 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-2L4lDghQxdGWWcO7 .icon-shape p,#mermaid-svg-2L4lDghQxdGWWcO7 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-2L4lDghQxdGWWcO7 .icon-shape .label rect,#mermaid-svg-2L4lDghQxdGWWcO7 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-2L4lDghQxdGWWcO7 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-2L4lDghQxdGWWcO7 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-2L4lDghQxdGWWcO7 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} P0 Baseline
P1 Context + Profile
P2 Durable Run
P3 Checkpoint Pause/Resume
P4 MQ + Outbox + Replay
P5 Lease + Fencing
P6 Capacity + Chaos + SLO

P0:固定现有行为

先为当前 Agent 调用链、流式输出、停止、摘要和历史加载建立契约测试。没有基线就开始重构,很难判断是 Harness 设计错误,还是旧行为本来就不稳定。

P1:上下文与用户画像

先以 Shadow 模式生成 Context Manifest,不改变模型输入。比较新旧上下文的 token、约束召回和回答质量,再灰度启用 ContextAssembler。

P2:持久化 Run

建立 Run、Attempt、Step、Event 和 Audit 数据模型。先使用单实例 Worker,让 SSE 与 Run 生命周期彻底解耦。

P3:Checkpoint Pause/Resume

接入框架 Checkpoint Store,把主 Agent 和搜索子 Agent 放入同一恢复协议。通过单实例重启和用户暂停测试证明恢复正确。

P4:MQ、Outbox 与事件重放

引入消息队列、Transactional Outbox、Consumer Inbox、DLQ 和 SSE 事件重放。验证消息重复和 Relay 崩溃不会产生重复有效执行。

P5:多实例接管

实现 Lease、Heartbeat、Recovery Scanner 和 Fencing。通过 Worker 硬崩溃、旧 Worker 复活和滚动发布测试。

P6:容量与生产硬化

根据目标规模进行并发 Run 压测,完成仪表盘、告警、Runbook、数据清理、混沌测试和灰度发布。

Feature Flag 应在 Run 创建时形成配置快照。不要在 Run 中途切换执行协议。回滚时停止新 Run 进入 Harness,已有 Run 继续完成或安全暂停,而不是删除运行状态。


十三、企业级 Agent Harness 检查清单

在宣布 Agent 已经"生产可用"之前,可以逐项检查:

生命周期

  • HTTP/SSE 断线不会自动销毁 Run;
  • Run、Attempt、Step 有独立标识;
  • 状态转换由显式状态机约束;
  • Pause 与 Cancel 是不同语义;
  • 任务可以在进程重启后恢复。

上下文与记忆

  • 有模型感知的 token 预算;
  • 系统策略和当前任务约束不会被静默裁剪;
  • 摘要有版本和覆盖消息边界;
  • 工作记忆与对话摘要分离;
  • 长期记忆可查看、修改和删除;
  • 工具大结果使用 Artifact,而不是永久进入 Prompt。

分布式正确性

  • 数据库是任务事实源;
  • 数据库与 MQ 之间使用 Outbox;
  • 消费者可以处理重复消息;
  • Worker 使用 Lease 和 Fencing;
  • 旧 Worker 恢复后不能写入;
  • 外部副作用有幂等键或明确的至少一次语义。

恢复与重试

  • Checkpoint 版本、校验和和保留期可管理;
  • 只有 Checkpoint 验证成功才进入 paused
  • 恢复时校验 Agent、Prompt、工具和模型版本;
  • 错误被分类为可重试、资源、永久和用户行为;
  • 重试受总时间、token、调用次数和费用预算约束。

可观测性与运维

  • Run ID 可以关联 Trace、日志和审计;
  • 异步消息传播 Trace Context;
  • 指标没有 Run ID 级高基数 Label;
  • 可以看到队列积压、Pause 延迟和恢复成功率;
  • 有 DLQ、故障接管和 Checkpoint 清理 Runbook;
  • 通过断线、崩溃、重复投递和旧 Worker 复活测试。

结语

Agent 工程化的分水岭,不是模型是否更聪明,也不是 Prompt 是否更长,而是系统能否回答这些朴素的问题:

  • 任务现在在哪里?
  • 谁拥有执行权?
  • 用户停止后能否继续?
  • 进程崩溃后从哪里恢复?
  • 为什么选择了这些上下文?
  • 是否重复调用了外部服务?
  • 这次运行花了多少时间、token 和费用?
  • 出错后能否被准确定位和安全重试?

一个成熟的 Harness 不会替 Agent 思考。它做的是更基础、也更重要的工作:为不确定的推理过程提供确定的运行边界。

从这个角度看,企业级 Agent 平台并不是"LLM 加几个工具",而是一套围绕 Run 建立的可靠执行系统。先把生命周期、上下文、状态和恢复做对,再谈更复杂的多 Agent 协作,往往会走得更稳,也更快。

相关推荐
hj2862517 小时前
Shell 脚本完整学习笔记
chrome·笔记·学习
码片向量7 小时前
状态空间模型SSM学习笔记
笔记·学习·ssm·语音增强·状态空间
一个处女座的程序猿8 小时前
Agent之Skill:ui-ux-pro-max-skill的简介、安装和使用方法、案例应用之详细攻略
ui·agent·ux·ui-ux-skill
万岳科技系统开发8 小时前
智慧医院小程序开发满足医疗机构多场景服务需求
数据库·学习·小程序
TrisighT8 小时前
让 Claude 半夜自己审 PR:Headless 模式 + GitHub Actions 实测
aigc·agent·ai编程
笑鸿的学习笔记8 小时前
C++笔记之内存映射、零拷贝与共享内存(学习三)-操作系统术语概念图
笔记·学习
leeyi8 小时前
多 Agent 编排:Eino ADK 的三种协作模式(第57篇-E43)
aigc·agent·ai编程
kisbad8 小时前
Day 012|Embedding 和向量数据库:知识库检索到底在检什么
数据库·python·embedding·agent
既然如此,那就开摆8 小时前
ULIP总结
笔记·学习