这是「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 每次决策要扫描所有工具描述。这带来三个问题:

  1. 选择困难:工具多了,LLM 容易选到相似但不正确的工具
  2. token 浪费:每次请求都带上 20 个工具的 schema,多烧几千 token
  3. 安全风险:在数据采集阶段就能调用删除工具,这不合理

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 篇,系列目录:

  1. 别再把 LLM 当聊天机器人了,这才是 Agent 的正确打开方式
  2. 三大基石之 LLM 调用与 Prompt 工程
  3. 三大基石之记忆系统
  4. 三大基石之工具调用
  5. Java 生态 Agent 框架横评
  6. 用 Spring AI 搭建你的第一个 Agent
  7. 本文:Harness Engineering —— 用约束把 LLM 的"野马"套上缰绳

更多推荐