AI 应用的 API 版本管理——Prompt 版本和 API 版本的协同
AI 应用的 API 版本管理——Prompt 版本和 API 版本的协同
"老张,我们的 AI 接口要升级,但有一批 App 用户还没有更新,新老客户端需要同时支持。"
这是一个很常见的需求,但在 AI 应用场景下比普通接口升级要复杂得多。
普通 REST 接口版本管理是这样的:v1 返回旧格式,v2 返回新格式,两套接口各自运行,老客户端用 v1,新客户端用 v2。逻辑清晰,实现简单。
但 AI 接口的版本管理多了一个独特的维度:Prompt 的版本。
你的 API 接口签名可能没变(还是 POST /api/v1/chat),但你悄悄改了底层的 System Prompt——也许是优化了回答风格,也许是修复了某个 Prompt 的 Bug——结果老客户端调用同一个接口,却发现 AI 的行为变了:以前输出 JSON,现在输出 Markdown;以前回答很简洁,现在变得啰嗦了。
这种"接口版本没变,但 AI 行为变了"的情况,是 AI 应用版本管理最大的挑战。
API 版本和 Prompt 版本的关系
先把两个概念理清楚:
API 版本:接口的请求和响应格式。客户端依赖这个,格式变了就是破坏性变更。
Prompt 版本:调用 AI 时使用的系统提示词(System Prompt)和用户提示词模板。这决定了 AI 的行为和输出风格。
这两个版本的变更会相互影响:
关键设计原则:
- API 版本决定输入输出格式的契约
- 每个 API 版本应该绑定一个 Prompt 版本范围(主版本对应)
- Prompt 的小版本更新(Bug 修复、措辞优化)可以在同一个 API 版本内进行
- Prompt 的大版本更新(行为或输出格式变化)必须伴随 API 版本升级
Prompt 版本的特殊挑战
挑战一:Prompt 变更难以测试
代码变更可以写单元测试,但 Prompt 变更的影响是模糊的。"改了一句话,AI 的回答风格会变吗?"——你需要跑大量真实请求才能有把握。
挑战二:Prompt 变更的回滚成本
如果 Prompt 更新上线后发现问题,你需要能快速回滚到上一个版本。这要求 Prompt 必须有版本控制和热更新能力。
挑战三:多租户下的版本隔离
如果你是 SaaS 产品,不同客户可能在不同的 API 版本上,他们的 Prompt 不能互相干扰。
Prompt 版本管理方案
存储方式
Prompt 不应该硬编码在代码里,应该有专门的存储和版本控制:
// Prompt 的数据模型
@Entity
@Table(name = "prompt_versions")
public class PromptVersion {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String promptKey; // Prompt 的唯一标识,如 "chat.system.v1"
private String version; // 语义版本号,如 "1.2.3"
private String content; // Prompt 内容(支持变量占位符)
private String apiVersion; // 关联的 API 版本
private boolean active; // 是否激活
private String description; // 变更说明
@CreationTimestamp
private LocalDateTime createdAt;
@Column(name = "created_by")
private String createdBy;
}Prompt 仓库接口
@Repository
public interface PromptVersionRepository extends JpaRepository<PromptVersion, Long> {
// 获取指定 API 版本下激活的最新 Prompt
@Query("""
SELECT p FROM PromptVersion p
WHERE p.promptKey = :key
AND p.apiVersion = :apiVersion
AND p.active = true
ORDER BY p.createdAt DESC
LIMIT 1
""")
Optional<PromptVersion> findActiveByKeyAndApiVersion(
@Param("key") String key,
@Param("apiVersion") String apiVersion);
// 获取指定版本号的 Prompt
Optional<PromptVersion> findByPromptKeyAndVersion(String promptKey, String version);
// 获取 Prompt 的所有历史版本
List<PromptVersion> findByPromptKeyOrderByCreatedAtDesc(String promptKey);
}Prompt 管理器(带缓存)
@Service
public class PromptManager {
private static final Logger log = LoggerFactory.getLogger(PromptManager.class);
@Autowired
private PromptVersionRepository promptRepository;
@Autowired
private RedisTemplate<String, String> redisTemplate;
private static final Duration CACHE_TTL = Duration.ofMinutes(10);
/**
* 根据 API 版本获取对应的激活 Prompt
*/
public String getActivePrompt(String promptKey, String apiVersion,
Map<String, String> variables) {
String cacheKey = "prompt:" + promptKey + ":" + apiVersion;
// 先查缓存
String cachedTemplate = redisTemplate.opsForValue().get(cacheKey);
if (cachedTemplate != null) {
return renderTemplate(cachedTemplate, variables);
}
// 查数据库
PromptVersion promptVersion = promptRepository
.findActiveByKeyAndApiVersion(promptKey, apiVersion)
.orElseThrow(() -> new PromptNotFoundException(
"未找到激活的Prompt: key=" + promptKey + ", apiVersion=" + apiVersion));
// 写缓存
redisTemplate.opsForValue().set(cacheKey, promptVersion.getContent(), CACHE_TTL);
return renderTemplate(promptVersion.getContent(), variables);
}
/**
* 渲染 Prompt 模板(替换变量占位符)
*/
private String renderTemplate(String template, Map<String, String> variables) {
if (variables == null || variables.isEmpty()) return template;
String result = template;
for (Map.Entry<String, String> entry : variables.entrySet()) {
result = result.replace("{{" + entry.getKey() + "}}", entry.getValue());
}
return result;
}
/**
* 热更新:当 Prompt 版本变更时,清除相关缓存
*/
@CacheEvict(cacheNames = "prompts", allEntries = true)
public void evictPromptCache(String promptKey, String apiVersion) {
String cacheKey = "prompt:" + promptKey + ":" + apiVersion;
redisTemplate.delete(cacheKey);
log.info("Prompt缓存已清除: key={}, apiVersion={}", promptKey, apiVersion);
}
/**
* 回滚到指定版本
*/
@Transactional
public void rollbackToVersion(String promptKey, String targetVersion) {
// 停用当前激活版本
List<PromptVersion> activeVersions =
promptRepository.findByPromptKeyOrderByCreatedAtDesc(promptKey)
.stream()
.filter(PromptVersion::isActive)
.collect(Collectors.toList());
activeVersions.forEach(v -> {
v.setActive(false);
promptRepository.save(v);
});
// 激活目标版本
PromptVersion targetPrompt = promptRepository
.findByPromptKeyAndVersion(promptKey, targetVersion)
.orElseThrow(() -> new PromptNotFoundException("未找到目标版本: " + targetVersion));
targetPrompt.setActive(true);
promptRepository.save(targetPrompt);
// 清除缓存
evictPromptCache(promptKey, targetPrompt.getApiVersion());
log.info("Prompt已回滚: key={}, version={}", promptKey, targetVersion);
}
}API 版本化 + Prompt 版本绑定的实现
API 版本路由配置
@Configuration
public class APIVersionConfig implements WebMvcConfigurer {
@Override
public void configurePathMatch(PathMatchConfigurer configurer) {
// 支持 URL 路径版本和请求头版本两种方式
}
}
/**
* API 版本注解
*/
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@RequestMapping
public @interface ApiVersion {
String[] value() default {};
}
/**
* 版本化请求处理条件
*/
@Component
public class APIVersionCondition implements RequestCondition<APIVersionCondition> {
private final String apiVersion;
public APIVersionCondition(String apiVersion) {
this.apiVersion = apiVersion;
}
@Override
public APIVersionCondition combine(APIVersionCondition other) {
return new APIVersionCondition(other.apiVersion);
}
@Override
public APIVersionCondition getMatchingCondition(HttpServletRequest request) {
// 从 URL 路径或请求头中提取版本号
String requestVersion = extractVersion(request);
if (this.apiVersion.equals(requestVersion)) {
return this;
}
return null;
}
@Override
public int compareTo(APIVersionCondition other, HttpServletRequest request) {
return other.apiVersion.compareTo(this.apiVersion);
}
private String extractVersion(HttpServletRequest request) {
// 优先从 URL 路径提取(如 /api/v2/chat)
String uri = request.getRequestURI();
Pattern versionPattern = Pattern.compile("/api/(v\\d+)/");
Matcher matcher = versionPattern.matcher(uri);
if (matcher.find()) {
return matcher.group(1);
}
// 其次从 Accept 头提取(如 application/vnd.laozhang.v2+json)
String accept = request.getHeader("Accept");
if (accept != null) {
Pattern acceptPattern = Pattern.compile("vnd\\.laozhang\\.(v\\d+)");
Matcher acceptMatcher = acceptPattern.matcher(accept);
if (acceptMatcher.find()) {
return acceptMatcher.group(1);
}
}
return "v1"; // 默认版本
}
}Controller 层版本化实现
/**
* API v1 聊天接口
* 对应 Prompt v1.x
*/
@RestController
@RequestMapping("/api/v1/chat")
public class ChatControllerV1 {
@Autowired
private ChatServiceV1 chatServiceV1;
@PostMapping
public ResponseEntity<ChatResponseV1> chat(
@RequestBody ChatRequestV1 request,
@RequestHeader(value = "X-User-Id", required = false) String userId) {
ChatResponseV1 response = chatServiceV1.chat(
request.getSessionId(),
request.getMessage(),
userId
);
return ResponseEntity.ok(response);
}
// v1 的响应格式(旧格式)
@Data
@Builder
public static class ChatResponseV1 {
private String sessionId;
private String content; // 纯文本内容
private long timestamp;
}
}
/**
* API v2 聊天接口
* 对应 Prompt v2.x,新增 citations 字段
*/
@RestController
@RequestMapping("/api/v2/chat")
public class ChatControllerV2 {
@Autowired
private ChatServiceV2 chatServiceV2;
@PostMapping
public ResponseEntity<ChatResponseV2> chat(
@RequestBody ChatRequestV2 request,
@RequestHeader(value = "X-User-Id", required = false) String userId) {
ChatResponseV2 response = chatServiceV2.chat(
request.getSessionId(),
request.getMessage(),
request.getOptions(),
userId
);
return ResponseEntity.ok(response);
}
// v2 的响应格式(新格式)
@Data
@Builder
public static class ChatResponseV2 {
private String sessionId;
private String content; // 内容
private List<Citation> citations; // 新增:引用来源
private Map<String, Object> metadata;
private long timestamp;
@Data
@Builder
public static class Citation {
private String source;
private String excerpt;
private double relevanceScore;
}
}
}Service 层绑定 Prompt 版本
@Service
public class ChatServiceV1 {
private static final String API_VERSION = "v1";
private static final String SYSTEM_PROMPT_KEY = "chat.system";
@Autowired
private ChatModel chatModel;
@Autowired
private PromptManager promptManager;
public ChatControllerV1.ChatResponseV1 chat(String sessionId,
String userMessage,
String userId) {
// 获取与 v1 API 绑定的 System Prompt
String systemPrompt = promptManager.getActivePrompt(
SYSTEM_PROMPT_KEY,
API_VERSION,
Map.of("userId", userId != null ? userId : "anonymous")
);
// 构建对话
List<Message> messages = new ArrayList<>();
messages.add(new SystemMessage(systemPrompt));
messages.add(new UserMessage(userMessage));
ChatResponse response = chatModel.call(new Prompt(messages));
String content = response.getResult().getOutput().getContent();
return ChatControllerV1.ChatResponseV1.builder()
.sessionId(sessionId)
.content(content)
.timestamp(System.currentTimeMillis())
.build();
}
}
@Service
public class ChatServiceV2 {
private static final String API_VERSION = "v2";
private static final String SYSTEM_PROMPT_KEY = "chat.system";
@Autowired
private ChatModel chatModel;
@Autowired
private PromptManager promptManager;
public ChatControllerV2.ChatResponseV2 chat(String sessionId,
String userMessage,
Object options,
String userId) {
// v2 使用不同的 Prompt 版本(要求 AI 返回带引用的格式)
String systemPrompt = promptManager.getActivePrompt(
SYSTEM_PROMPT_KEY,
API_VERSION,
Map.of(
"userId", userId != null ? userId : "anonymous",
"outputFormat", "include_citations"
)
);
List<Message> messages = new ArrayList<>();
messages.add(new SystemMessage(systemPrompt));
messages.add(new UserMessage(userMessage));
ChatResponse response = chatModel.call(new Prompt(messages));
String rawContent = response.getResult().getOutput().getContent();
// v2 需要解析 AI 返回的带引用格式
ParsedResponse parsed = parseV2Response(rawContent);
return ChatControllerV2.ChatResponseV2.builder()
.sessionId(sessionId)
.content(parsed.getContent())
.citations(parsed.getCitations())
.timestamp(System.currentTimeMillis())
.build();
}
private ParsedResponse parseV2Response(String rawContent) {
// 解析 AI 返回的带引用格式
// 实际解析逻辑根据 Prompt 中定义的格式实现
return new ParsedResponse(rawContent, new ArrayList<>());
}
}Prompt 版本管理的 Admin 接口
@RestController
@RequestMapping("/admin/prompts")
@PreAuthorize("hasRole('ADMIN')")
public class PromptAdminController {
@Autowired
private PromptManager promptManager;
@Autowired
private PromptVersionRepository promptRepository;
/**
* 查看当前激活的 Prompt 版本
*/
@GetMapping("/{promptKey}/active")
public ResponseEntity<?> getActiveVersion(
@PathVariable String promptKey,
@RequestParam String apiVersion) {
return promptRepository
.findActiveByKeyAndApiVersion(promptKey, apiVersion)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
/**
* 发布新版本的 Prompt
*/
@PostMapping("/{promptKey}/versions")
public ResponseEntity<?> publishNewVersion(
@PathVariable String promptKey,
@RequestBody PublishPromptRequest request,
Principal principal) {
// 停用当前版本
// 发布新版本
PromptVersion newVersion = new PromptVersion();
newVersion.setPromptKey(promptKey);
newVersion.setVersion(request.getVersion());
newVersion.setContent(request.getContent());
newVersion.setApiVersion(request.getApiVersion());
newVersion.setActive(true);
newVersion.setDescription(request.getDescription());
newVersion.setCreatedBy(principal.getName());
promptRepository.save(newVersion);
promptManager.evictPromptCache(promptKey, request.getApiVersion());
return ResponseEntity.ok(Map.of(
"status", "published",
"version", request.getVersion()
));
}
/**
* 回滚到指定版本
*/
@PostMapping("/{promptKey}/rollback")
public ResponseEntity<?> rollback(
@PathVariable String promptKey,
@RequestParam String targetVersion) {
promptManager.rollbackToVersion(promptKey, targetVersion);
return ResponseEntity.ok(Map.of(
"status", "rolled_back",
"targetVersion", targetVersion
));
}
}总结
AI 应用的版本管理比普通 API 多了 Prompt 这个维度,核心设计原则是:
API 版本和 Prompt 主版本绑定:API v1 只用 Prompt v1.x,API v2 只用 Prompt v2.x,不能混用。
Prompt 支持热更新:不要把 Prompt 写死在代码里,存数据库 + Redis 缓存,支持运行时更新和回滚。
Prompt 小版本可以静默更新,大版本变更必须评估对 API 行为的影响,必要时同步升级 API 版本。
版本变更要有测试保障:Prompt 变更上线前,用测试集跑一轮对比,确保新版本的行为符合预期。
