为什么 2026 是后端工程师做 Agent 的主场:从 Prompt 到 Harness

我今年被问得最多的问题,已经不再是"你怎么写提示词",而是"你的 Agent 半夜挂了,第二天早上怎么把它恢复并解释清楚发生了什么"。

第二类问题是一个标准的后端问题:状态在哪、重试怎么幂等、日志怎么回放、权限怎么收口。所以先给结论:2026 年做 Agent 真正的壁垒不在 Prompt,而在 Harness。 Prompt 只是这个系统最上面那层接口,Harness 才是服务端。

一、先分层:Prompt 只是最薄的顶层

大部分团队卡住的地方,是把 Agent 当成一个"对话功能"接进系统,于是所有复杂度都堆在提示词里;而真正决定它能不能上生产的,是下面几层。

这张图想让你看见什么:注意那条从"产物与轨迹日志"回到"上下文装配"的箭头。它表示一次执行的结果必须能被下一次上下文装配读到,否则你的 Agent 永远只有单轮智商。上面三层靠模型和写作能力,下面三层靠工程能力,而且下面三层决定了上面三层能不能稳定复现。

二、Harness 的六块,对应后端的六种老能力

我把 Harness 拆成六块。每一块我都写清"后端已有的能力可以直接平移什么",因为这是我说"主场"的唯一依据。

Harness 关注点 后端已有的对应物 可以直接平移什么 常见陷阱
上下文与记忆 组装 DTO、拼装读模型、多级缓存 分层装配、按需裁剪、TTL 与失效 把所有历史一次性塞进去,延迟与成本双爆
工具调用契约 RPC 接口、OpenAPI、DTO 校验 schema 校验、版本兼容、错误码分层 用自然语言约定参数,模型自由发挥
状态机与重试 订单状态机、幂等键、退避重试 幂等键、可重试错误分类、上限与熔断 把"模型再想一次"当成重试
权限与沙箱 网关鉴权、RBAC、容器隔离 能力清单、最小权限、旁路审批 让模型自己决定调哪个高权限工具
可观测性与回放 链路追踪、审计日志、事件溯源 trace 粒度到"一次工具尝试"、可重放 只记最终答案,中间轨迹全丢
成本控制 限流、配额、预算告警 按任务预算、按租户配额、超支降级 只统计 token 总量,不按任务归因

上下文与记忆

上下文不是"越多越好",而是"装配得越准越好"。这点和读模型拼装完全同构:同一个实体,列表页要 5 个字段,详情页要 40 个字段,你绝不会给列表页返回详情页的 DTO。Anthropic 在 2025-09-29 的《Effective context engineering for AI agents》里把上下文当成一种有限预算资源来管理,这个视角对后端来说几乎是无门槛的。

工具调用契约

这是我认为最被低估的一块。工具签名不是文档,是契约。参数必须可校验、错误必须可分类、返回必须可截断。模型上下文协议 MCP 在 2026-07-28 版规范 里明确用 JSON-RPC 2.0 承载消息,并区分 Host、Client、Server 三种角色——这就是一次典型的"把不确定的自然语言收敛成有边界的协议"。

状态机与重试

Agent 的一次任务不是一次调用,而是一条带分支的状态机:思考、选工具、执行、观察、再思考、收尾。后端做这件事有成熟范式,不需要发明新理论。

权限与沙箱

模型没有"意图可信"这回事。工具有多危险,就要有多重的沙箱。最小权限、白名单、超时、资源配额,这些在网关和容器层已经做完了,搬过来即可。

可观测性与回放

我踩过的坑在这里:我早期只记录最终的模型输出,结果线上一个工具偶发超时导致的任务失败,我复盘了三个小时——因为我不知道是第几次尝试、传了什么参数、错误码是什么。后来我把 trace 粒度下沉到"一次工具尝试",并且把每次的入参、出参、错误分类、耗时全部落盘,问题五分钟就定位了。可回放不是锦上添花,它是 Harness 的第一性需求:没有回放,你连"这次改动是不是变好了"都无法判断。

成本控制

成本要按任务归因,不能只按天统计总量。一个 30 轮的重试循环,token 消耗可能是一次正常任务的 20 倍,而你在日报里看到的只是"今天用量涨了 15%"。

三、一次失败重试到底长什么样

上面说的是静态结构,真正让 Agent 从 demo 变成系统的,是失败路径。下面这张时序图是我认为每个 Agent harness 都必须能画出来的图。

这张图想让你看见什么:注意"复用同一幂等键"这一步。这是整个 harness 里最容易写错、也最能区分工程水平的地方——如果重试换了幂等键,下游就会执行两次,你的 Agent 会开始重复下单、重复发券、重复扣款。模型永远不该负责这件事,它只负责说"我要调这个工具",剩下的语义由 harness 保证。

四、把"重试"写成有幂等键的状态机

光说抽象没用,下面是我在项目里用的一段骨架,语言是 Java,因为这套东西最终要落在服务端。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
public final class ToolInvoker {

public enum Outcome { OK, RETRYABLE, FATAL, EXHAUSTED }

public record ToolCall(String name, String argsJson) {}
public record ToolResult(String summary) {}

public static class ToolException extends RuntimeException {
public ToolException(String msg) { super(msg); }
}
public static class RetryableToolException extends ToolException {
public RetryableToolException(String msg) { super(msg); }
}
public static class FatalToolException extends ToolException {
public FatalToolException(String msg) { super(msg); }
}

public interface ToolClient {
ToolResult execute(ToolCall call, String idempotencyKey, long timeoutMillis);
}

public interface TraceStore {
void record(ToolCall call, int attempt, Outcome outcome, String detail);
}

private static final int MAX_ATTEMPTS = 3;

private final ToolClient client;
private final TraceStore trace;

public ToolInvoker(ToolClient client, TraceStore trace) {
this.client = client;
this.trace = trace;
}

public ToolResult invoke(ToolCall call, String idempotencyKey) {
RuntimeException last = null;
for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
ToolResult result = client.execute(call, idempotencyKey, 30_000L);
trace.record(call, attempt, Outcome.OK, result.summary());
return result;
} catch (FatalToolException e) {
trace.record(call, attempt, Outcome.FATAL, e.getMessage());
throw e;
} catch (RetryableToolException e) {
last = e;
trace.record(call, attempt, Outcome.RETRYABLE, e.getMessage());
backoff(attempt);
}
}
trace.record(call, MAX_ATTEMPTS, Outcome.EXHAUSTED, String.valueOf(last));
throw new ToolException("tool exhausted after " + MAX_ATTEMPTS + " attempts: " + call.name());
}

private void backoff(int attempt) {
try {
Thread.sleep(200L * (1L << (attempt - 1)));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new ToolException("interrupted during backoff");
}
}
}

这段代码在说明什么:说明重试的核心不是循环,而是三件确定的事——错误被分成可重试和不可重试两类(fatal 立刻抛,不再浪费两次调用预算)、幂等键从调用方一路传到工具服务且全程不变(重试语义安全)、每一次尝试无论成败都写进 trace(可回放)。模型在这里完全没有出场机会,这正是我想强调的分工:模型负责决策,harness 负责语义安全。

五、我的判断:哪类团队现在做 Agent 是浪费

我的判断是:2026 年做 Agent 踩空最多的不是技术选型,而是前提不成立。下面两种团队,现在投入大概率是浪费。

第一种,数据边界不清的团队。 他们连"哪些数据可以出内网、哪些字段必须脱敏、哪些调用要审批"都答不出来,却已经在讨论多 agent 协作。这类团队做出来的东西,Demo 阶段惊艳,安全评审阶段直接死掉。边界不清的时候,任何 harness 都是给漏洞加速。

第二种,没有真实工具面的团队。 如果 Agent 能调用的"工具",本质上只有搜索加总结,那它就不需要 harness,只需要一个更好的搜索框。Harness 的价值来自"真实副作用":写库、发消息、下单、改配置。没有副作用,就没有幂等、没有权限、没有回滚,也就没有工程壁垒。

反过来,两类团队是刚需:一是已经有稳定内部平台和大量 API、正在被"人肉操作 SOP"拖住的团队;二是把 Agent 当成线上服务而不是功能点的团队,他们天然需要 SLA、trace、回放和预算。

六、这里我不太同意:模型跃迁会不会把 harness 吃掉

我不太同意"harness 只是过渡方案,模型一强就没了"这个流行说法,但我也不同意把它说成永久壁垒。我的理解是:模型变强会吃掉 harness 的"策略层",但吃不掉"责任层"。

策略层指的是那些靠提示词技巧凑出来的自愈能力,比如让模型自己决定要不要重试、自己判断该不该相信工具返回。这部分确实会被吃掉,而且我认为应该被吃掉,因为它本来就不该由模型承担。

责任层吃不掉:幂等键写在哪、权限谁批、审计留存多久、出了问题由谁向业务方解释。这些是组织责任,不是推理能力。模型再强,也不会替你承担一次重复扣款的后果。

反例也要说清:如果某天模型能稳定输出带可验证契约的工具调用,并且平台层直接内建了重试与沙箱(这在 MCP 这类协议演进中是可见趋势),那么今天很多手写 harness 会退化成"配置"。所以"后端主场"这句话成立的条件是:你做的 harness 解决的是责任与可复现问题,而不是在替模型补智力。 如果只是后者,你的代码就是一层迟早要被删掉的胶水。

还有一个来自 Java 生态的现实提醒:JDK 27 于 2026-09-15 GA,它并不是 LTS——按 Oracle 官方路线图,LTS 只有 8、11、17、21、25,下一个是 2027 年 9 月的 Java 29。而 JDK 27 里的结构化并发还停在 JEP 533 第七次预览。这两件事放在一起说明:Agent harness 里最需要的"可取消、可超时、可收敛"的并发原语,在 JVM 上还没有定稿,你得自己包一层。这也是后端工程师的主场,但不是一个舒服的主场。

七、公开信号与待核项

可核实的公开信号,我按对本文论点的重要性排序:

待核的两项,我不编:

  1. "Agent 相关岗位占比"这类招聘数据——待核。 我没有可核查的一手来源,只看到二手转述,无法确认统计口径与样本,所以本文不引用任何招聘比例数字。
  2. “模型跃迁能替代多少比例的手写 harness”——待核。 这需要同一批任务在新旧模型上的对照实验,我没有做过,公开资料也没有可对齐的基准,任何具体百分比我都不会写。

八、可执行清单

  1. 先画你自己那条失败路径的时序图。如果画不出来,说明你的 Agent 还没有 harness,只有一个 while 循环。
  2. 给每一个工具有副作用的调用加幂等键,并规定"重试不得换键"这一条硬规则。
  3. 把错误分成 fatal 与 retryable 两类,fatal 立刻失败,别把重试当成"让模型再想想"。
  4. 把 trace 粒度下沉到"一次工具尝试",字段至少包含 attempt、入参摘要、错误分类、耗时。
  5. 给每个任务设预算上限,超支降级而不是静默继续,并按任务归因统计成本。
  6. 在动手前先回答三个问题:数据边界在哪、工具的真实副作用是什么、出问题谁负责。三个都答不上来,就先别做 Agent。