news 2026/11/2 5:12:43

Spring AI 的 Harness Engineering:从 Prompt 模板到 Agent 运行时

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring AI 的 Harness Engineering:从 Prompt 模板到 Agent 运行时

Spring AI 在国内 Java 圈最近是真的火,不少团队已经从“用 RestTemplate 调 OpenAI”进化到了“Spring AI + 大模型”的阶段。但我在帮几个朋友项目做技术评审时发现一个共同现象:大家的代码里全是PromptTemplate+ChatClient的直调,接口一多、场景一变,代码就开始失控。说白了,很多人还停留在“把 Prompt 当字符串拼接”的阶段,根本没进入“Agent 运行时”的思维。

这篇文章想聊一个我最近在反复琢磨的概念:Harness Engineering。它不是说让你去搞一套重型框架,而是提醒我们——围绕 LLM 的能力,要做工程约束、编排、治理和可观测。标题有点扎心:Java Spring AI 作为国内 Java 生态接入 LLM 最顺手的路径,逃得过 Prompt 模板的诱惑,但逃不过 Harness Engineering 的必然性。下面是基于我用 Spring AI 搭 Agent 运行时的一些实测记录、踩坑日记和设计思路,写给正准备从“demo 阶段”往“生产阶段”走的 Java 开发者。

1. 为什么说“Prompt 模板”撑不起真正的 Agent

1.1 从“问个问题”到“要干活”:需求的本质变化

先还原一个常见的演进路径。最早接到需求,往往是“帮我总结一篇文章”“帮我翻译一段话”。这种任务用一个PromptTemplate就够:

PromptTemplate template = new PromptTemplate("请将以下内容翻译成英文:\\n{content}"); Prompt prompt = template.create(Map.of("content", content));

这阶段没啥问题,模型一次性输出,你拿字符串去用就完事了。但随着需求变复杂,情况就变了:

  1. 多步任务:用户说“帮我查一下最近的订单,如果金额超过一万就生成一封催款邮件”。这不是一次模型调用能解决的,你需要先调用订单查询工具,拿到结果后让模型决定下一步。
  2. 条件分支:模型需要根据中间结果决定调用哪个工具、是否继续、是否需要向用户确认。
  3. 长期记忆:多轮对话中,用户可能改了需求,模型必须记住上下文。
  4. 权限与安全:工具调用得有边界,不能让模型随便调删除接口。
  5. 成本与耗时控制:一次任务可能产生十几次模型调用,怎么控制总 token 消耗和响应时间。

一旦需求变成“要干活”,Prompt 模板就只是一个碎片化的零件,而不是系统。你缺的是对整个执行过程的管理:谁来循环、谁来终止、谁负责兜底、错误怎么恢复。这些东西合在一起,其实就是 Agent 的运行时。

1.2 Harness Engineering:不是玄学,是把 LLM“套上缰绳”

第一次听到 Harness Engineering 这个概念,我脑子里蹦出来的是“马具”——给马套上缰绳、鞍具,让它按人的方向跑,而不是放任它四处乱窜。放在 AI 工程里其实就是这个意思:

LLM 是一个很强但很不稳定的“动力源”,你要做的不是膜拜它的能力,而是给它设计一套可控制、可观测、可恢复的工程结构。

我理解 Harness Engineering 在 AI Agent 场景下至少包含四层能力:

层次要解决的问题工程手段
约束层Prompt 注入、敏感词、输出格式失控输入校验、输出校验、分隔符策略、权限白名单
编排层多步任务谁来调度、怎么循环、何时终止任务循环、状态机、工具注册表、路由策略
可观测层模型调了哪些工具、花了多少 token、哪一步出错审计日志、链路追踪、token 计量、耗时统计
恢复层模型返回格式错误、工具调用失败、上下文超长重试、降级、截断、人工兜底

这四层,恰好是 Spring AI 生态里官方没有直接给你做成一体,却把积木全给齐了的东西。你拿ChatClient、Advisor、Tool、ChatMemory自己拼,拼出来就是“Agent 运行时”。

提示:不要把“框架叫不叫 Agent 框架”看得太重。Spring AI 官方文档里没有给你一个AgentExecutor,但基于它的扩展点,你完全可以搭出轻量级的运行时。关键是你有没有“工程化”的意识,而不是代码里有没有Agent这两个字母。

1.3 关键认知:Agent 不是“模型会调用工具”,而是“运行时能承接失败”

这里必须纠正一个很常见的误区。不少人以为,只要给模型注册了一个@Tool,它就会乖乖调用工具、处理结果、返回正确答案。实测告诉我:模型对工具的调用,本质上是一种概率行为,不是可靠行为。

它会:

  • 该调工具时不调,凭空编造数据;
  • 调了工具但不按
  • JSON Schema 来传参,导致解析失败;
  • 工具返回结果后,它看不懂或曲解结果;
  • 陷入循环:反复调用同一个工具,越调越迷茫。

所以,如果你把“模型调用工具”当成理所当然,那你的 Agent 就是个“概率性正确”的玩具。真正的 Agent 运行时,核心职责就是承接失败:捕捉解析异常、检测循环、超时兜底、必要时让模型重来或者直接转人工。Harness Engineering 的“缰绳”,主要就是套在这些不稳定的环节上。

2. Spring AI 给 Java 开发者留下了哪些可编排的基础

2.1 ChatClient 和 PromptTemplate:入门利器,但不是终点

Spring AI 1.0 之后,ChatClient成了主推的入口。它的流式调用、call()/stream()重载、entity()自动映射到 POJO 等功能,确实比原生 SDK 顺手得多。比如你可以这么干:

ChatClient client = ChatClient.builder(chatModel).build(); String answer = client.prompt() .system("你是一个订单助手,回答用户关于订单的问题。") .user("帮我查一下订单 #20240801 的状态") .call() .content();

但如果仅止步于此,它和“用模板拼字符串”没有本质区别。你要想真正编排,就必须用它的扩展点,尤其是Advisor链。

2.2 Advisor 链:拦截器思想在 AI 世界中彻底重演

如果你写过 Spring MVC 的拦截器、Spring AOP 的切面,或者写过 Dubbo 的 Filter,你会秒懂Advisor的定位:在模型调用前后插入逻辑。

Spring AI 的Advisor接口很简单,核心方法:

Advisor advisor = (Advisor) (request, chain) -> { // 调用前:可以改 prompt、加上下文、校验内容 // 调用:chain.call(request) // 调用后:可以改 output、记录日志、做数据脱敏 };

它的价值在于——你终于可以在不改业务代码的情况下,给所有 AI 调用统一套上治理逻辑。

我当时把下面这些逻辑全部做成了 Advisor:

  1. 输入安全过滤器:检测 prompt 里有没有指令注入(比如“忽略之前所有指令”这类模式)。
  2. 输出校验器:要求模型输出 JSON 时,先做语法校验再返回。
  3. 审计日志 Advisor:记录每次请求的 user prompt、system prompt、响应的前 500 字、token 用量。
  4. 脱敏 Advisor:在 prompt 发给模型之前,把身份证号、手机号等敏感信息替换成占位符;拿到响应后再还原。

这其实就是 Harness Engineering 里的“约束层”。没有 Advisor 之前,做这些要全靠手动在业务代码里调,痛苦且极易漏。

2.3 Tool / Function Calling:让 Agent 真正能“干活”

Spring AI 的@Tool注解让 Java 开发者注册工具变得非常爽:

@Component public class OrderTools { @Tool("根据订单号查询订单状态和金额") public OrderInfo getOrderStatus(String orderId) { // 调用订单服务 } }

只要把这个 Bean 作为tool传进去,Spring AI 会自动生成 OpenAI 格式的 function schema,并在模型决定调用时把它代理到这个方法上。这背后的原理是关键:模型不直接执行工具,而是输出一段结构化的“调用请求”,由 Spring AI 解析后反射调用你的方法。

听起来很完美,实战里的坑却不少,我放在下一部分详细说。但先记住一点:工具层是 Agent 的“手脚”,但你得给“手脚”画好边界。比如删除操作、写操作,一定要加权限校验;工具方法的入参要设计得尽量简单,避免复杂嵌套对象。

2.4 ChatMemory:别小看了多轮对话的“记忆工程”

很多人觉得记忆就是把历史消息丢给模型。错。工程上要头疼的是:

  1. 上下文长度限制:模型窗口有限,历史消息全塞进去很快就爆。
  2. 摘要压缩:长对话要把早期内容压缩成摘要。
  3. 多会话隔离:用户 A 的记忆不能漏给用户 B。
  4. 窗口策略选择:只保留最近 N 条,还是按 token 数切割?

Spring AI 提供ChatMemory接口,常用实现是InMemoryChatMemory,可以按conversationId存取消息:

ChatMemory chatMemory = new InMemoryChatMemory(); ChatClient client = ChatClient.builder(chatModel) .defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory)) .build(); // 使用时传入 conversationId,自动从历史中加载上下文 String answer = client.prompt() .user("继续我们刚才的话题") .advisors(a -> a.param(ChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, "user-001")) .call() .content();

但实测下来,InMemoryChatMemory只适合单机 demo。生产环境要么改成 Redis 存储,要么自己实现ChatMemory接口。这块细节后面会讲。

3. 从零到一个能跑起来的 Agent 运行时:实操记录

3.1 分层设计:入口、编排器、执行器、工具层

我不会直接上 StateMachine 或者工作流引擎,那对大多数场景是过度设计。最实用的分层是四层:

  • 入口层(Controller / MQ Consumer):接收用户请求,负责鉴权、会话绑定。
  • 编排层(AgentOrchestrator):决定“要不要继续调用模型”“调哪个工具”“何时结束”。
  • 执行层(Executor):真正调用 ChatClient,处理工具结果,管理重试。
  • 工具层(ToolRegistry):注册的工具集合,附带校验、权限、熔断逻辑。

编排层是核心,我用一个很朴素的循环来实现:

public AgentResult run(AgentRequest request) { int maxIterations = 5; List<Message> messages = new ArrayList<>(buildInitialMessages(request)); for (int i = 0; i < maxIterations; i++) { ChatResponse response = chatClient.prompt() .messages(messages) .tools(toolRegistry.getAllToolCallbacks()) .call() .chatResponse(); AssistantMessage assistantMessage = response.getResult().getOutput(); messages.add(assistantMessage); if (assistantMessage.hasToolCalls()) { // 执行工具调用,把结果作为 ToolResponseMessage 加回 messages List<ToolResponseMessage> toolResults = executeToolCalls(assistantMessage.getToolCalls()); messages.addAll(toolResults); continue; // 继续下一轮 } // 没有工具调用,说明模型准备输出最终结果 return AgentResult.success(assistantMessage.getText()); } return AgentResult.failure("超过最大迭代次数,任务中止"); }

注意几个细节:

  1. maxIterations必须硬限制,否则模型可能疯狂循环,把你 token 烧光。
  2. messages手动管理,而不直接用 ChatMemory Advisor,是为了在编排循环里完全掌控上下文。
  3. 每次循环工具的注入顺序要保持一致,虽然模型大概率不受影响,但实测不一致会偶发“幻觉工具调用”。

3.2 用 Advisor 搭护栏:输入校验、输出校验、审计日志

运行时跑起来后,第一件事是加护栏。我的经验是,用三个 Advisor 搞定:

@Bean public Advisor safetyAdvisor() { return (request, chain) -> { String userText = request.getInstructions().stream() .map(Instruction::getText) .filter(Objects::nonNull) .collect(Collectors.joining(" ")); // 简单的注入检测:出现这类模式就拒绝请求 if (userText.matches(".*(忽略|无视|ignore|disregard).*(指令|提示|规则).*")) { throw new SecurityException("检测到疑似指令注入,已拦截"); } return chain.call(request); }; } @Bean public Advisor auditAdvisor(ObjectMapper mapper) { return (request, chain) -> { long start = System.currentTimeMillis(); try { ChatResponse response = chain.call(request); // 记录审计日志:请求内容、响应摘要、耗时、模型、token auditService.log(request, response, System.currentTimeMillis() - start); return response; } catch (Exception e) { auditService.logError(request, e); throw e; } }; } @Bean public Advisor outputGuardAdvisor() { return (request, chain) -> { ChatResponse response = chain.call(request); String output = response.getResult().getOutput().getText(); // 如果业务要求返回 JSON,这里做预校验 if (request.getInstructions().stream() .anyMatch(ins -> "json".equals(ins.getMetadata().get("requireJson")))) { if (!isValidJson(output)) { throw new RuntimeException("模型输出不是合法 JSON,终止处理"); } } return response; }; }

这里有个容易被忽略的细节:Advisor 的执行顺序很重要。我建议“输入校验 → 审计 → 模型调用 → 输出校验 → 审计响应”,所以要用@Order或者显式指定顺序。如果顺序反了,审计日志就记不到被拦截的请求了,等于白做。

3.3 工具注册与参数 Schema 的坑

Spring AI 的@Tool用起来简单,但到了复杂场景就碰到硬茬:

坑一:嵌套对象参数。如果你的工具方法是updateOrder(OrderRequest req),而OrderRequest里有枚举、日期、嵌套对象,模型生成的参数大概率对不齐。实测下来,模型对复杂 JSON Schema 的参数生成相当不稳定。

我的对策是:把工具参数直接拍平,只用基本类型和简单 String。

@Tool("更新订单状态。orderId:订单号;status:新状态(枚举值:CREATED,PAID,SHIPPED,DONE);remark:备注") public boolean updateOrder(String orderId, String status, String remark) { // ... }

这样模型每次生成参数都不容易错。如果确实需要复杂对象,我会在工具方法内部再做一次解析。

坑二:工具描述不够明确。模型选择工具的行为很大程度依赖@Tool的描述。我见过有人写@Tool("处理订单"),结果模型完全不知道什么时候该用它。正确姿势是描述清楚“这个工具的触发条件 + 入参含义 + 返回值”。比如上面那个例子,我会写:

@Tool("当用户要求修改订单状态时调用。注意:只有订单创建者或管理员可以调用。orderId为订单号;status为目标状态。")

坑三:工具数量不要贪多。注册十几个工具,模型选择时不仅耗时变长,错误率也会升高。除非业务必要,我建议控制在 8 个以内。超过 8 个,考虑分组路由:先用一个路由工具决定走哪个业务域,再到具体工具域里选。

3.4 会话级状态管理与超时、重试策略

Agent 运行时里还有个很容易被忽视的东西:会话状态。比如用户在进行一个“售后工单处理流程”,中途可能走了 3 步:描述问题 → 上传凭证 → 确认退款。每一步的中间结果放哪里?

我的方案是引入一个AgentSession对象,存在 Redis 里:

public class AgentSession { private String sessionId; private String userId; private Map<String, Object> state = new ConcurrentHashMap<>(); private int step; private Instant lastActiveTime; }

每一步的工具调用都可能读取/更新这个 state。好处是:Agent 不再是“一次性问答”,而是有状态的“会话式业务流程”。这也更贴近真实的业务系统。

超时与重试我也给出实测推荐值:

场景推荐策略
ChatClient call 超时30 秒(视模型而定,OpenAI 系建议 60 秒)
单次工具执行5 秒,超时记录错误并让模型“换个方式”
编排循环最大轮次5 轮
重试策略指数退避 + 最大 2 次重试
并发控制对同一 session 加分布式锁,避免并发改写 state

注意:不要对模型调用做“重试无脑拉满”的配置。模型侧偶发超时还好,如果它产生了重复循环,重试只会加速烧钱。

4. 效果不理想?八成是运行时而非模型的问题

4.1 工具调用“空转”与死循环:如何退避、限流

我调试第一个真实 Agent 时,模型在“查订单状态 → 发现已发货 → 查物流 → 再看一眼订单”之间来回跳,连续循环了 8 次还没停,最终靠 maxIterations 兜底中止。单纯扩大循环上限显然是死路,更科学的做法是识别重复模式。

简单有效的方案:在编排层记录最近 N 次工具调用签名,如果发现同一个工具 + 相同参数出现两次以上,就主动干预。

private Set<String> recentCallSignatures = new HashSet<>(); boolean isRepeatedCall(String toolName, String argSignature) { String key = toolName + "|" + argSignature; if (recentCallSignatures.contains(key)) { return true; } recentCallSignatures.add(key); return false; }

检测到重复后,我的处理方式不是直接报错,而是给模型注入一条系统提示:“你刚刚已经查过订单 #20240801 的状态了,结果是已发货。请不要重复调用,直接基于现有信息回答用户。”实测这种“温柔干预”比硬中止效果好,用户感知也更平滑。

4.2 模型“自说自话”不回工具:要不要强插提示?

另一类高频问题是反过来的:模型明明该调用工具,它偏不调,自己编答案。比如用户问“我上周的订单到哪了”,模型直接回答“您的订单已发货,预计明天到达”——它根本没查过物流。

应对策略有两个方向:

  1. Prompt 强制约束:在 system prompt 里明确写死“如果你需要订单数据,必须先调用 getOrderStatus 工具,不要猜测”。
  2. 输出校验拦截:让输出内容里包含明确的“数据置信标记”,如果模型回答里有数字、日期但又不来自工具结果,直接判定为幻觉,强制它重新回答。

实测下来,强插提示只解决一部分问题。最终可靠的做法,是在运行时里规定“工具结果为事实来源”:核心业务数据必须由工具返回,模型只是负责组织语言。如果工具没有返回某字段,模型回答就不能包含该字段。这个规则写成 system prompt 的一部分,再配合输出校验,效果好了非常多。

4.3 可观测性:从“玄学调优”到“数据调优”

很多团队做 Agent,调优全凭感觉。同一个 Prompt,今天好明天坏,不知道为什么。我的做法是把可观测性做进运行时:

  1. 每次模型调用记录:模型名、Prompt token、Completion token、总耗时、工具调用列表、是否成功。
  2. 每次 Agent 任务记录:总轮次、总 token、总耗时、结果状态(成功/失败/超时)、终止原因。
  3. 定期聚合分析:哪个工具调用失败率最高、哪类 prompt 最容易触发幻觉、哪个环节 token 消耗最大。

有了这些数据,你再回头看那些“玄学”问题,会发现大部分都能找到规律。比如我之前发现一个会话的 token 消耗异常高,追查日志发现是InMemoryChatMemory在长会话中把历史消息全量带上,导致 prompt 越来越长、响应越来越慢。这个问题不看数据,光靠猜是猜不出来的。

5. 常见问题与排查速查(含避坑技巧)

5.1 高频问题实录

现象可能原因排查方向我的处理
模型反复调用同一个工具上下文缺乏约束,模型没理解“结果已经拿到”查看最近几轮消息,确认工具结果是否被模型理解注入“结果已获取,不要重复”的提示,或识别重复调用签名直接截断
工具参数解析失败参数类型复杂、嵌套层级太深查看 Spring AI 生成的 JSON Schema拍平参数为基本类型,或在描述里写明取值枚举
模型返回 JSON 但解析失败模型输出被 markdown 包裹、或有额外说明文本打印原始响应输出校验 Advisor 里做提取+校验,必要时让模型只输出 JSON
同一 session 上下文错乱多请求并发写同一个 conversationId检查是否存在并发对 session 加分布式锁
token 消耗激增上下文无裁剪、历史消息全量携带统计每次调用 prompt token 变化实现窗口截断 + 摘要压缩,按 token 上限裁剪
模型不调用工具,瞎编数据工具描述不清晰、prompt 约束不够检查 @Tool 描述和 system prompt强约束“数据必须来自工具结果”,输出校验拦截无来源数据

5.2 几个必须避开的“大坑”

坑一:上下文无限膨胀。如果你的ChatMemory不做窗口限制,长会话会越跑越慢、越跑越贵。我建议按 token 数裁剪:超过 8000 token 就把最早的消息移除,再超过 12000 token 就对前面内容做一轮摘要。摘要可以用单独的模型调用生成,成本比全量携带低得多。

坑二:工具数量失控。我已经反复强调过,工具是“越少越好”。每加一个工具,模型选错的概率就多一分。架构上可以分层,但单个 Agent 的工具集一定要精简。

坑三:忽略输出校验。模型的输出是自由文本,你只凭“prompt 要求它输出 JSON”就相信它会输出合法 JSON,这是在赌博。所有结构化输出都要过一层校验,校验失败就重试或降级。

坑四:测试环境与生产环境用同一个模型 Key。测试时刷的 token 是真实成本。我建议测试环境用低配模型或者加限流,避免月底账单爆炸。

6. 一些摸爬滚打后的个人体会

回到开头的话题。我用 Spring AI 把 Agent 从“Prompt 模板”阶段推进到“可编排运行时”阶段,最大的感触是:真正花时间的不是写工具、不是调 Prompt,而是想清楚运行时怎么兜住各种失败。模型的能力边界你控制不了,但工程上能控制的是:上下文给它喂什么、工具允许它碰什么、输出怎么校验、循环怎么终止、失败怎么恢复。

这正好是 Harness Engineering 的核心含义——用一个扎实的“马具”,把 LLM 这匹烈马驾驭在业务轨道上。Spring AI 没有直接给你一整套编排运行时,但它提供的Advisor、ChatMemory、@Tool、ChatClient这些积木足够你搭建一版轻量级的运行时。别指望框架替你解决所有问题,工程的责任始终在你这边。

如果你也在用 Spring AI 做 Agent,建议先别急着加新功能,停下来审视一下自己的代码:你的模型调用链路上,有没有护栏?有没有日志?循环有没有兜底?上下文有没有裁剪?这几个问题回答不上来,那就说明你还没走到 Agent 运行时,还在 Prompt 模板的舒适区里打转。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/2 5:12:40

VoiceStudio:语音开发工具的核心概念与工程实践

我无法基于当前输入生成符合要求的博文。原因如下&#xff1a;输入中仅提供了项目标题"VoiceStudio"&#xff0c;但未提供任何实质性的【项目正文】、【关键词】或【摘要描述】&#xff1b;所谓“相关热搜词”和“最新网络热词”部分为空&#xff0c;未给出具体词汇&…

作者头像 李华
网站建设 2026/11/2 5:10:57

iOS后门TriangleDB技术解析与防护方案

1. TriangleDB后门技术解析1.1 后门工作机制剖析TriangleDB是一种针对iOS设备的复杂后门程序&#xff0c;其名称来源于其独特的"三角测量"数据收集架构。这个后门主要通过三个核心组件协同工作&#xff1a;数据采集模块&#xff1a;使用Objective-C编写的底层hook组件…

作者头像 李华
网站建设 2026/11/2 5:10:54

静态IP不限流量真相:地址资源与流量计费的两本账

1. 先搞清楚“静态IP”到底在卖什么很多人第一次接触静态IP这个概念&#xff0c;是在购买云服务器或者办理企业专线的时候。页面上赫然写着“静态IP”“固定公网地址”&#xff0c;旁边还标注着“不限流量”“无需额外购买”。第一反应往往是&#xff1a;这东西是不是有什么猫腻…

作者头像 李华
网站建设 2026/11/2 5:09:01

位运算技巧:如何高效找出数组中唯一出现的数字

1. 问题背景与核心需求这道题目来自LeetCode热题HOT100系列的第136题&#xff0c;属于经典的位运算应用场景。给定一个非空整数数组&#xff0c;其中除某个元素外&#xff0c;其余每个元素均出现两次&#xff0c;要求找出那个只出现一次的元素。题目特别强调需要设计时间复杂度…

作者头像 李华
网站建设 2026/11/2 5:08:34

Java反射原理与性能优化:Class对象到Method.invoke避坑指南

简介&#xff1a;面向Java初中级开发者与求职者的反射专题面试题PDF&#xff0c;围绕11个高频问题梳理反射机制的核心考点与应答思路&#xff0c;适合面试前集中复习、日常开发中查漏补缺。资源包仅含1个PDF文件&#xff0c;大小约569KB&#xff0c;轻量便携&#xff0c;可直接…

作者头像 李华
网站建设 2026/11/2 5:08:00

流媒体视频下载全链路:m3u8分片解析与ffmpeg音视频合流

很多人第一次想把自己收藏的视频存到本地&#xff0c;都是右键另存为&#xff0c;结果拿到的要么是个几 KB 的网页&#xff0c;要么是一段没有声音的画面。这个现象背后不是"平台藏得深"&#xff0c;而是现在的视频分发早就从"一个 MP4 文件"变成了"一…

作者头像 李华