AI Agent 开发实战(七):Harness Engineering —— 用约束把 LLM 的“野马“套上缰绳
这是「AI Agent 开发实战」系列的第 7 篇。上一篇我们用 Spring AI 跑通了一个能调用工具的 Agent,感受了"LLM 自主决策"的爽感。但生产环境光爽不行——LLM 会幻觉、会乱调工具、会输出一坨无法解析的文本。
读完本文,你会理解 Harness Engineering 的三大约束手段,学会用 Plan 模板、工具注册约束和输出 Schema 让 Agent 既能干话又可控。
一、为什么 LLM 需要"套缰绳"?
先看几个真实事故场景:
场景 1:幻觉工具调用
用户:帮我查下订单状态
LLM:好的,我来调用 deleteOrder 工具帮你处理...
结果:订单被删了 💀
场景 2:输出格式漂移
第 1 轮:{"action": "search", "query": "iPhone 15"}
第 2 轮:我来帮你搜索一下 iPhone 15 的信息(纯文本,无法解析)
第 3 轮:```json\n{"action": "search"...}\n```(带了 markdown 代码块标记)
结果:下游 JSON 解析器炸了 3 次 💥
场景 3:无限工具循环
LLM:调用 searchTool → 结果不满意 → 再调一次 → 还不满意 → 再调...
结果:12 次工具调用,token 烧了 80k,用户等了 40 秒 ⏰
核心矛盾:LLM 越强大,“自由度"越高;但生产系统需要"确定性”。
Harness Engineering 就是解决这个矛盾的方法论——不是限制 LLM 的能力,而是给能力划一条安全赛道。
没有 Harness 的 Agent 有 Harness 的 Agent
│ │
├── LLM 自由决策一切 ├── LLM 在 Plan 模板内决策
├── 所有工具随时可用 ├── 按阶段暴露不同工具子集
├── 输出格式靠 prompt "请求" ├── 输出格式靠 Schema 强制
├── 不可预测、不可控 ├── 可预测、可回溯
├── 适合 Demo ├── 适合生产
└── 调试靠猜 └── 调试靠日志
二、Harness Engineering 三大支柱
┌──────────────────────────────────────────────────────┐
│ Harness Engineering 三大支柱 │
│ │
│ ① Plan 模板约束 │
│ └── 预定义执行流程,LLM 在框架内填充细节 │
│ │
│ ② 工具注册约束 │
│ └── 按阶段/角色限制可用工具,防止误操作 │
│ │
│ ③ 输出 Schema 约束 │
│ └── 结构化输出 + 校验,拒绝非格式数据 │
│ │
├──────────────────────────────────────────────────────┤
│ 本质:把"信任 LLM"变成"约束 LLM" │
│ 目标:自由度 × 确定性 = 最大生产价值 │
└──────────────────────────────────────────────────────┘
| 约束手段 | 控制什么 | 不控制什么 | 类比 |
|---|---|---|---|
| Plan 模板 | 执行步骤和顺序 | 每步的具体内容 | 高速公路车道 |
| 工具注册 | 可用工具集 | 工具的调用参数 | 工具箱分区 |
| 输出 Schema | 输出结构和类型 | 输出的具体值 | 表单模板 |
三、支柱一:Plan 模板约束
3.1 问题:LLM 自己规划的流程不靠谱
你让 LLM “帮用户分析数据并生成报告”,它可能:
- 第 1 轮直接写报告,数据都没查
- 第 2 轮查了数据但忘了分析
- 第 3 轮分析了但报告格式不对
Plan 模板的思路:你预先定义好步骤骨架,LLM 只负责每一步的执行细节。
3.2 固定 Plan 模板
最简单的约束——固定流程,不让 LLM 自由发挥:
public class DataAnalysisPlan {
// 预定义的执行步骤,顺序固定
private static final List<PlanStep> STEPS = List.of(
new PlanStep("collect", "数据采集", "调用数据查询工具,获取原始数据"),
new PlanStep("analyze", "数据分析", "对采集到的数据进行统计分析"),
new PlanStep("visualize","数据可视化", "生成图表描述"),
new PlanStep("report", "报告生成", "汇总分析结果,生成最终报告")
);
public PlanStep getCurrentStep(int stepIndex) {
if (stepIndex >= STEPS.size()) {
throw new IllegalStateException("所有步骤已完成");
}
return STEPS.get(stepIndex);
}
public boolean isComplete(int stepIndex) {
return stepIndex >= STEPS.size();
}
}
执行时,每一步只给 LLM 当前步骤的上下文:
public String executeStep(ChatClient client, PlanStep step, Map<String, Object> context) {
String prompt = """
你当前处于「%s」阶段。
任务说明:%s
上一阶段的结果:%s
请执行当前阶段的任务,只输出当前阶段的结果。
""".formatted(step.getName(), step.getDescription(),
context.getOrDefault("previousResult", "无"));
return client.prompt(prompt)
.tools(step.getAllowedTools()) // 按阶段限制工具
.call()
.content();
}
3.3 条件 Plan 模板
固定模板太死板?加条件分支:
public class ConditionalPlan {
public List<PlanStep> buildPlan(String userQuery) {
List<PlanStep> plan = new ArrayList<>();
// 第一步永远是意图识别
plan.add(new PlanStep("classify", "意图识别", "判断用户意图类型"));
// 根据意图走不同分支
String intent = classifyIntent(userQuery);
switch (intent) {
case "data_query" -> {
plan.add(new PlanStep("query", "数据查询", "执行数据库查询"));
plan.add(new PlanStep("format", "结果格式化", "将查询结果格式化展示"));
}
case "report" -> {
plan.add(new PlanStep("collect", "数据采集", "多维度数据采集"));
plan.add(new PlanStep("analyze", "数据分析", "统计分析"));
plan.add(new PlanStep("report", "报告生成", "生成结构化报告"));
}
case "chitchat" -> {
plan.add(new PlanStep("reply", "直接回复", "无需工具,直接回答"));
}
}
return plan;
}
}
3.4 Plan 模板 vs ReAct 自由模式
| 维度 | ReAct 自由模式 | Plan 模板约束 |
|---|---|---|
| 流程控制 | LLM 自主决定下一步 | 预定义步骤,顺序固定 |
| 灵活性 | 高,可动态调整 | 中,支持条件分支 |
| 可预测性 | 低,每次执行路径可能不同 | 高,执行路径可枚举 |
| 调试难度 | 难,需要回放完整 trace | 易,按步骤定位问题 |
| 适用场景 | 探索性任务、开放问答 | 固定业务流程、生产管线 |
经验法则:生产环境优先用 Plan 模板,探索性任务用 ReAct,两者可以混用——Plan 模板控制大流程,每个步骤内部允许 ReAct 微循环。
四、支柱二:工具注册约束
4.1 问题:工具越多,LLM 越容易选错
你给 Agent 注册了 20 个工具,LLM 每次决策要扫描所有工具描述。这带来三个问题:
- 选择困难:工具多了,LLM 容易选到相似但不正确的工具
- token 浪费:每次请求都带上 20 个工具的 schema,多烧几千 token
- 安全风险:在数据采集阶段就能调用删除工具,这不合理
4.2 按阶段暴露工具
核心思路:只在需要时才把工具给 LLM。
public class ToolRegistry {
// 工具池:所有已注册的工具
private final Map<String, List<Object>> stageTools = Map.of(
"collect", List.of(queryTool, searchTool),
"analyze", List.of(statsTool, aggregateTool),
"visualize", List.of(chartTool),
"report", List.of(formatTool, exportTool),
// 删除工具只在专门的"管理员确认"阶段才暴露
"admin", List.of(deleteTool, updateTool)
);
public List<Object> getToolsForStage(String stage) {
return stageTools.getOrDefault(stage, Collections.emptyList());
}
}
在 Spring AI 中的实践:
@Configuration
public class AgentToolConfig {
@Bean
public QueryTool queryTool() { return new QueryTool(); }
@Bean
public StatsTool statsTool() { return new StatsTool(); }
@Bean
public ChartTool chartTool() { return new ChartTool(); }
@Bean
public DeleteTool deleteTool() { return new DeleteTool(); }
}
@Service
public class AgentExecutor {
@Autowired
private QueryTool queryTool;
@Autowired
private StatsTool statsTool;
@Autowired
private ChartTool chartTool;
@Autowired
private DeleteTool deleteTool;
public String executeByStage(String stage, String prompt) {
ChatClient client = chatClientBuilder.build();
return switch (stage) {
case "collect" -> client.prompt(prompt)
.tools(queryTool) // 只给查询工具
.call().content();
case "analyze" -> client.prompt(prompt)
.tools(statsTool) // 只给分析工具
.call().content();
case "visualize" -> client.prompt(prompt)
.tools(chartTool) // 只给图表工具
.call().content();
default -> throw new IllegalArgumentException("未知阶段: " + stage);
};
}
}
4.3 工具描述的约束
工具的 @Tool 描述本身就是约束——描述越精确,LLM 误用的概率越低:
public class OrderTools {
// ❌ 模糊描述,容易被误用
@Tool(description = "删除订单")
public void deleteOrder(String orderId) { ... }
// ✅ 精确描述 + 前置条件 + 影响范围
@Tool(description = """
删除指定订单。⚠️ 此操作不可逆。
前置条件:订单状态必须为"已取消"或"已关闭"。
调用前必须先调用 getOrderStatus 确认状态。
仅在用户明确要求删除时使用。
""")
public void deleteOrder(String orderId) { ... }
}
4.4 工具调用次数限制
防止 LLM 陷入工具调用死循环:
public class ToolCallLimiter {
private final int maxToolCalls;
private int currentCalls = 0;
public ToolCallLimiter(int maxToolCalls) {
this.maxToolCalls = maxToolCalls;
}
public void beforeToolCall(String toolName) {
if (currentCalls >= maxToolCalls) {
throw new ToolCallLimitExceededException(
"工具调用次数已达上限(" + maxToolCalls + ")," +
"当前调用:" + toolName
);
}
currentCalls++;
}
public int getRemainingCalls() {
return maxToolCalls - currentCalls;
}
}
在 Spring AI 中通过 Advisor 实现:
public class ToolLimitAdvisor implements CallAdvisor {
private final ToolCallLimiter limiter;
@Override
public adviseRequest(ChatRequest request, ChatResponse response) {
// 在每次工具调用前检查
request.getToolCalls().forEach(call -> {
limiter.beforeToolCall(call.name());
});
// 把剩余次数注入 prompt,让 LLM 知道预算
if (limiter.getRemainingCalls() <= 2) {
request.addSystemMessage(
"⚠️ 你还剩 " + limiter.getRemainingCalls() + " 次工具调用机会,请谨慎使用。"
);
}
return request;
}
}
五、支柱三:输出 Schema 约束
5.1 问题:LLM 的输出不守规矩
你让 LLM 输出 JSON,它可能给你:
情况 1:加了多余的解释
好的,这是你要的结果:{"action": "search", "query": "iPhone"}
希望对你有帮助!
情况 2:JSON 格式错误
{"action": "search", "query": "iPhone 15",} ← 尾巴多了逗号
情况 3:字段名不一致
{"action_type": "search", "q": "iPhone"} ← 和约定字段名不一样
情况 4:类型错误
{"action": "search", "limit": "十"} ← 应该是数字,给了中文
5.2 用 JSON Schema 约束
定义严格的 Schema,在 LLM 输出后做校验:
public class OutputSchema {
// 定义输出 Schema
public static final String ACTION_SCHEMA = """
{
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["search", "create", "update", "delete"]
},
"query": {
"type": "string",
"minLength": 1,
"maxLength": 200
},
"confidence": {
"type": "number",
"minimum": 0,
"maximum": 1
}
},
"required": ["action", "query"],
"additionalProperties": false
}
""";
// 校验 + 修复
public ActionOutput parseAndValidate(String llmOutput) {
// 1. 剥离可能的 markdown 代码块标记
String json = stripCodeFences(llmOutput);
// 2. 尝试解析 JSON
JsonObject obj;
try {
obj = JsonParser.parseString(json).getAsJsonObject();
} catch (Exception e) {
// 3. 解析失败,走修复流程
obj = repairJson(llmOutput);
}
// 4. Schema 校验
validateSchema(obj);
return new ActionOutput(
obj.get("action").getAsString(),
obj.get("query").getAsString(),
obj.has("confidence") ? obj.get("confidence").getAsDouble() : 0.5
);
}
private String stripCodeFences(String output) {
String trimmed = output.trim();
if (trimmed.startsWith("```")) {
// 去掉 ```json 或 ```开头和 ```结尾
trimmed = trimmed.replaceAll("^```(json)?\\s*", "")
.replaceAll("\\s*```$", "");
}
return trimmed;
}
private void validateSchema(JsonObject obj) {
if (!obj.has("action")) {
throw new SchemaViolationException("缺少必填字段: action");
}
String action = obj.get("action").getAsString();
if (!List.of("search", "create", "update", "delete").contains(action)) {
throw new SchemaViolationException("非法 action 值: " + action);
}
}
}
5.3 Spring AI 的结构化输出
Spring AI 内置了 entity() 方法,可以直接把 LLM 输出映射到 Java 对象:
public record ActionOutput(
@JsonProperty(required = true) String action,
@JsonProperty(required = true) String query,
@JsonProperty(defaultValue = "0.5") double confidence
) {}
// 调用时直接指定输出类型
ActionOutput result = chatClient.prompt(prompt)
.tools(queryTool)
.call()
.entity(ActionOutput.class);
// Spring AI 底层做了:
// 1. 把 ActionOutput 的结构转成 JSON Schema 注入 prompt
// 2. LLM 输出后自动解析为 Java 对象
// 3. 解析失败会自动重试(最多 3 次)
原理拆解:
你写的代码 底层发生了什么
│ │
├── .entity(ActionOutput.class) ├── 1. 用反射读取 ActionOutput 字段
│ ├── 2. 生成 JSON Schema
│ ├── 3. 把 Schema 拼进 system prompt
│ │ "请严格按照以下 JSON 格式输出:..."
│ ├── 4. 调用 LLM
│ ├── 5. 拿到输出
│ ├── 6. stripCodeFences + JSON 解析
│ ├── 7. 校验字段类型
│ └── 8. 失败则重试(带错误信息再问一次)
│
└── 拿到 ActionOutput 对象 └── 干净的 Java 对象,可直接用
5.4 多级容错策略
LLM 输出
│
├─ 第 1 级:直接解析
│ └─ 成功 → 返回结果 ✅
│ └─ 失败 → 进入第 2 级
│
├─ 第 2 级:修复后解析
│ ├─ 去除 markdown 标记
│ ├─ 修复尾逗号
│ ├─ 补全缺失的括号
│ └─ 成功 → 返回结果 ✅
│ └─ 失败 → 进入第 3 级
│
├─ 第 3 级:带错误信息重试
│ ├─ 把解析错误发回给 LLM
│ ├─ "你上次的输出解析失败:xxx,请修正后重新输出"
│ └─ 成功 → 返回结果 ✅
│ └─ 失败 → 进入第 4 级
│
└─ 第 4 级:降级兜底
├─ 返回默认值 / 空结果
├─ 记录日志 + 告警
└─ 转人工处理 ⚠️
代码实现:
public class ResilientOutputParser<T> {
private final int maxRetries;
private final Class<T> targetType;
public T parse(String rawOutput, ChatClient client, String originalPrompt) {
// 第 1 级:直接解析
try {
return tryParse(rawOutput);
} catch (Exception e1) {
log.warn("第 1 级解析失败: {}", e1.getMessage());
}
// 第 2 级:修复后解析
try {
String repaired = repair(rawOutput);
return tryParse(repaired);
} catch (Exception e2) {
log.warn("第 2 级修复解析失败: {}", e2.getMessage());
}
// 第 3 级:带错误信息重试
for (int i = 0; i < maxRetries; i++) {
String retryPrompt = originalPrompt +
"\n\n你上次的输出无法解析,错误信息:" + e2.getMessage() +
"\n请严格按照 JSON 格式重新输出,不要包含任何额外文本。";
String retryOutput = client.prompt(retryPrompt).call().content();
try {
return tryParse(retryOutput);
} catch (Exception e3) {
log.warn("第 3 级重试 {} 失败: {}", i + 1, e3.getMessage());
}
}
// 第 4 级:降级兜底
log.error("所有解析尝试失败,返回默认值。原始输出: {}", rawOutput);
return defaultValue();
}
}
六、三大支柱协同:一个完整示例
把三大约束组合起来,构建一个"数据分析 Agent":
@Service
public class DataAnalysisAgent {
private final ChatClient chatClient;
private final ToolRegistry toolRegistry;
private final PlanTemplate planTemplate;
public AnalysisResult analyze(String userQuery) {
// ① Plan 模板约束:预定义 4 步流程
List<PlanStep> steps = planTemplate.buildPlan(userQuery);
Map<String, Object> context = new HashMap<>();
context.put("userQuery", userQuery);
for (int i = 0; i < steps.size(); i++) {
PlanStep step = steps.get(i);
// ② 工具注册约束:每步只暴露相关工具
List<Object> tools = toolRegistry.getToolsForStage(step.getId());
String prompt = buildStepPrompt(step, context);
String rawOutput = chatClient.prompt(prompt)
.tools(tools.toArray())
.call()
.content();
// ③ 输出 Schema 约束:每步输出结构化校验
StepResult result = parseStepResult(rawOutput, step);
context.put("step_" + step.getId(), result);
context.put("previousResult", result.getData());
}
return buildFinalResult(context);
}
private String buildStepPrompt(PlanStep step, Map<String, Object> context) {
return """
你是一个数据分析 Agent,当前处于「%s」阶段。
用户需求:%s
上一阶段结果:%s
请执行当前阶段任务。输出格式必须为 JSON:
{
"summary": "本步骤执行摘要(一句话)",
"data": "本步骤的输出数据",
"next_action": "continue" 或 "abort"
}
""".formatted(
step.getName(),
context.get("userQuery"),
context.getOrDefault("previousResult", "无")
);
}
}
执行流程图:
用户输入:"分析上季度销售数据"
│
├── ① Plan 模板:4 步固定流程
│ │
│ ├── Step 1: 数据采集
│ │ ├── ② 工具约束:只给 queryTool, searchTool
│ │ ├── LLM 执行 → 查询数据库
│ │ └── ③ Schema 校验 → {"summary":"已查询", "data":[...]}
│ │
│ ├── Step 2: 数据分析
│ │ ├── ② 工具约束:只给 statsTool, aggregateTool
│ │ ├── LLM 执行 → 统计计算
│ │ └── ③ Schema 校验 → {"summary":"环比增长15%", "data":{...}}
│ │
│ ├── Step 3: 数据可视化
│ │ ├── ② 工具约束:只给 chartTool
│ │ ├── LLM 执行 → 生成图表配置
│ │ └── ③ Schema 校验 → {"summary":"折线图", "data":{...}}
│ │
│ └── Step 4: 报告生成
│ ├── ② 工具约束:只给 formatTool, exportTool
│ ├── LLM 执行 → 汇总报告
│ └── ③ Schema 校验 → {"summary":"季度报告", "data":"..."}
│
└── 返回最终报告 ✅
七、约束的代价:不是越严越好
约束带来确定性,但也带来副作用。需要找到平衡点:
| 约束程度 | 优点 | 副作用 | 适用场景 |
|---|---|---|---|
| 零约束 | 最大灵活性 | 不可控、不可预测 | 实验探索 |
| 轻约束(仅 Schema) | 输出可控 | 流程仍可能跑偏 | 简单问答 |
| 中约束(Schema + 工具限制) | 输出可控、工具安全 | 流程仍可能不合理 | 多轮对话 |
| 重约束(三支柱全开) | 全程可控 | 灵活性差、开发成本高 | 生产管线 |
| 死约束(硬编码流程) | 100% 确定性 | LLM 退化成模板填充 | 简单固定任务 |
过度约束的信号:
⚠️ 你的 Plan 模板有 20 个步骤 → 考虑是否需要 LLM,硬编码可能更好
⚠️ 每步只有 1 个工具 → LLM 没有选择空间,直接调函数即可
⚠️ Schema 字段超过 30 个 → LLM 很难一次性填对所有字段
⚠️ 重试 3 次还在失败 → prompt 或 Schema 设计有问题,不是 LLM 的锅
经验法则:从轻约束开始,遇到问题逐级加约束。不要一上来就全副武装。
八、可观测性:约束的效果要看数据
上了约束之后,怎么知道有没有效果?关键指标:
Agent 可观测性仪表盘
│
├── 流程指标
│ ├── Plan 完成率:98.2% (目标 > 95%)
│ ├── 平均步骤数:3.7 步 (预期 4 步)
│ └── 异常中止率:1.8% (目标 < 5%)
│
├── 工具指标
│ ├── 工具调用成功率:96.5%
│ ├── 平均工具调用数:2.3 次/会话 (目标 < 5)
│ ├── 工具选择错误率:3.2% (加了约束前是 12%)
│ └── 触发次数限制:0 次 (说明 LLM 没有死循环)
│
├── 输出指标
│ ├── Schema 校验通过率:99.1% (第 1 级直接通过)
│ ├── 修复后通过率:0.7% (第 2 级修复后通过)
│ ├── 重试后通过率:0.2% (第 3 级重试后通过)
│ └── 降级兜底率:0.0% (第 4 级兜底,理想为 0)
│
└── 性能指标
├── 平均延迟:3.2 秒
├── P99 延迟:8.5 秒
└── 平均 token 消耗:2,100 token/会话
在 Spring AI 中通过 Micrometer 采集:
@Component
public class AgentMetrics {
private final MeterRegistry registry;
public void recordStep(String stepName, boolean success, long durationMs) {
registry.counter("agent.step.total",
"step", stepName, "result", success ? "success" : "failure"
).increment();
registry.timer("agent.step.duration",
"step", stepName
).record(durationMs, TimeUnit.MILLISECONDS);
}
public void recordToolCall(String toolName, boolean success) {
registry.counter("agent.tool.call",
"tool", toolName, "result", success ? "success" : "failure"
).increment();
}
public void recordSchemaValidation(int level, boolean success) {
registry.counter("agent.schema.validation",
"level", "level_" + level, "result", success ? "pass" : "fail"
).increment();
}
}
九、小结
Harness Engineering 三大支柱
│
├── Plan 模板约束
│ ├── 固定流程 → 最高确定性
│ ├── 条件分支 → 兼顾灵活性
│ └── 本质:你定赛道,LLM 跑
│
├── 工具注册约束
│ ├── 按阶段暴露 → 减少干扰
│ ├── 精确描述 → 减少误用
│ ├── 次数限制 → 防止死循环
│ └── 本质:给工具箱分区上锁
│
└── 输出 Schema 约束
├── JSON Schema 定义 → 结构契约
├── 多级容错解析 → 鲁棒性
├── Spring AI .entity() → 开箱即用
└── 本质:给输出套模板
| 要点 | 说明 |
|---|---|
| 约束不是限制能力 | 是给能力划安全边界 |
| 从轻到重 | 先跑通再加约束,别过度设计 |
| 可观测性是前提 | 没有指标就不知道约束有没有用 |
| Plan + 工具 + Schema | 三者协同,缺一不可 |
| 过度约束的信号 | 步骤太多、工具太少、字段太多 |
下一篇我们聊 输出 Schema 约束的进阶:当简单 JSON 不够用时,怎么用 JSON Schema 的高级特性(oneOf、$ref、条件校验)来约束更复杂的输出结构,以及怎么在 Spring AI 中自定义 Schema 生成策略。
这是「AI Agent 开发实战」系列第 7 篇,系列目录:
- 别再把 LLM 当聊天机器人了,这才是 Agent 的正确打开方式
- 三大基石之 LLM 调用与 Prompt 工程
- 三大基石之记忆系统
- 三大基石之工具调用
- Java 生态 Agent 框架横评
- 用 Spring AI 搭建你的第一个 Agent
- 本文:Harness Engineering —— 用约束把 LLM 的"野马"套上缰绳
更多推荐
所有评论(0)