别再把 Prompt 写成散落的字符串:Java 项目里的提示词工程应该像接口一样管理
很多 Java 项目接入大模型时,第一版代码通常长这样:在 Service 里拼一个字符串,塞几个变量,然后调用模型。Demo 能跑,效果也还行。问题出现在上线以后:产品改了口径,运营加了规则,模型输出偶尔不符合 JSON,排查时没人知道当前 Prompt 改过几次。
Prompt Engineering 在工程里不是“写一句更聪明的话”,而是把模型输入变成一份可维护的协议。它应该和接口参数、SQL、配置文件一样,被版本管理、被校验、被测试,而不是藏在 Java 字符串拼接里。
Prompt 不是文案,是输入协议
后端开发者很容易低估 Prompt 的工程属性。因为它看起来像自然语言,不像代码,也不像配置。但从系统角度看,Prompt 至少承担了四件事:
- 定义模型要扮演的角色
- 定义业务规则和边界
- 定义输入变量如何被理解
- 定义输出格式如何被下游代码消费
只要下游代码依赖模型输出,Prompt 就已经是系统契约的一部分。
比如一个客服工单分类场景,模型返回的分类会进入数据库、触发 SLA、分配处理人。此时“请你判断工单类型”不是一句提示语,而是分类服务的输入协议。它改了,系统行为就会改。
最常见的错误:在 Service 里拼字符串
错误写法一般不难识别:
String prompt = "你是客服助手,请判断用户问题属于哪个类型。用户问题:" + content;
String result = chatClient.prompt()
.user(prompt)
.call()
.content();
这种写法有几个隐患。
第一,Prompt 和业务代码耦合。以后想比较两个版本的提示词,只能翻 Git diff,甚至要从代码里拆。
第二,变量没有边界。content 太长怎么办?里面包含“忽略以上规则”怎么办?空字符串怎么办?代码很难看出这些问题。
第三,输出格式靠模型自觉。下游如果期望 JSON,但 Prompt 里只是口头要求“返回 JSON”,线上迟早会遇到多余解释、字段缺失、枚举值漂移。
更合理的做法是:Prompt 模板外置,变量显式传入,输出结构化,关键样例进入测试。
用 Spring AI 把 Prompt 放回工程体系

Spring AI 官方文档中提供了 Prompt、PromptTemplate、ChatClient、结构化输出等能力。具体 API 会随版本变化,实际项目里应以官方文档为准,但整体工程思路是稳定的:把提示词模板、模型调用和输出映射拆开。
可以先把模板放到资源文件中,例如:
你是一个客服工单分类助手。
请根据用户提交的问题,将工单分类为以下之一:
- ACCOUNT:账号、登录、权限相关
- PAYMENT:支付、退款、发票相关
- BUG:系统错误、功能不可用
- OTHER:无法判断或不属于以上类型
要求:
1. 只基于用户问题判断,不要编造背景信息
2. 如果无法确定,返回 OTHER
3. 输出必须符合指定结构
用户问题:
{content}
然后在 Java 代码里显式传入变量,而不是手写字符串拼接:
public record TicketClassifyResult(String category, String reason) {
}
@Service
public class TicketClassifyService {
private final ChatClient chatClient;
public TicketClassifyService(ChatClient.Builder builder) {
this.chatClient = builder.build();
}
public TicketClassifyResult classify(String content) {
if (content == null || content.isBlank()) {
return new TicketClassifyResult("OTHER", "用户问题为空");
}
String normalized = content.length() > 1000
? content.substring(0, 1000)
: content;
return chatClient.prompt()
.user(user -> user
.text("""
你是一个客服工单分类助手。
请把用户问题分类为 ACCOUNT、PAYMENT、BUG、OTHER 之一。
如果无法确定,返回 OTHER。
用户问题:
{content}
""")
.param("content", normalized))
.call()
.entity(TicketClassifyResult.class);
}
}
这个例子故意很短。真实项目里,模板更适合放到独立文件,或者进入配置中心、Prompt 管理表、版本化仓库。Service 只负责传参、调用和处理结果。
输出结构比“语气优化”更重要
很多人做 Prompt 优化时,先改语气:更专业一点、更详细一点、更像专家一点。工程项目里优先级通常不是这个。
更关键的是输出是否稳定。
如果模型结果要进入 Java 对象,就应该尽量使用结构化输出。OpenAI 官方文档也强调可以通过结构化输出让模型结果符合给定 Schema。Spring AI 也提供结构化输出相关能力,可以将模型响应映射为目标类型。
这背后的工程价值很直接:把“不知道模型会返回什么”变成“模型应当返回这个结构,失败时可以被检测”。
例如分类结果不要设计成随意文本:
这个问题大概是支付类,因为用户提到了退款。
而应该设计成可消费对象:
{
"category": "PAYMENT",
"reason": "用户提到了退款诉求"
}
这样做以后,后端可以继续做枚举校验、日志记录、失败重试和人工兜底。Prompt 不再是孤立文本,而是进入了 Java 类型系统。
给 Prompt 加三类测试
Prompt 测试不一定一上来就做复杂评估平台。第一版可以很朴素,但一定要有。
第一类是固定样例测试。比如准备 20 条典型工单,覆盖账号、支付、Bug、其他几类。每次改 Prompt 后跑一遍,确认核心样例没有退化。
第二类是边界样例测试。比如空输入、超长输入、带注入语句的输入、多个问题混在一起的输入。这类测试不是为了证明模型“永远安全”,而是为了发现明显不稳的输入。
第三类是版本对比测试。上线前让新旧 Prompt 同时跑一批历史数据,比较分类变化。变化本身不一定是坏事,但必须知道它变在哪里。
一个很简单的测试数据可以这样设计:
record PromptCase(String input, String expectedCategory) {
}
List<PromptCase> cases = List.of(
new PromptCase("我登录的时候一直提示验证码错误", "ACCOUNT"),
new PromptCase("申请退款三天了还没到账", "PAYMENT"),
new PromptCase("点击保存按钮页面直接白屏", "BUG"),
new PromptCase("你们公司地址在哪里", "OTHER")
);
真正上线时,不建议只用断言卡死全部结果。因为模型输出存在概率性,评估更适合看通过率、关键错误率、人工抽检结果和版本差异。Java 项目可以先把这些评估结果写入日志或测试报告,再逐步接入更完整的评估平台。
变量校验要放在模型调用之前
Prompt 注入不是只有安全团队才需要关心。只要用户输入会进入 Prompt,就应该做基础治理。
至少要处理三件事。
一是长度限制。不要把完整聊天记录、整篇文档、无边界用户输入直接塞进 Prompt。上下文窗口不是免费资源,也不是越长越好。
二是字段隔离。系统规则、业务规则、用户输入要分清楚,不要把它们混成一段话。Anthropic 的 Prompt Engineering 文档中也提到可用清晰结构来区分不同内容块,这对复杂提示词尤其有帮助。
三是失败兜底。模型返回无法解析、枚举值非法、置信不足时,系统要能降级。例如返回 OTHER、进入人工复核、记录异常样例,而不是让后续流程继续使用脏数据。
这和我们写接口很像:Controller 参数要校验,DTO 字段要约束,数据库写入前要检查。Prompt 变量也一样,只是很多团队一开始没有把它当成正式输入。
第一版落地可以很轻
如果项目刚开始接入 AI,不需要马上做一个复杂的 Prompt 平台。可以先做到四点:
- Prompt 模板不要散落在 Service 字符串里
- 变量进入模型前要校验和截断
- 输出尽量映射成 Java 类型
- 每次改 Prompt 至少跑一组固定样例
做到这一步,AI 功能就已经从“凭感觉调效果”进入了“能改、能测、能排查”的状态。
对 Java 后端来说,Prompt Engineering 最值得借鉴的不是那些神奇话术,而是工程纪律:输入有边界,输出有结构,变更有记录,质量有回归。模型可以不稳定,但系统不能把这种不稳定原样传递给业务链路。
更多推荐



所有评论(0)