Spring AI 上线要补齐哪些工程配置
围绕 Spring AI Agent 从本地走向生产,介绍密钥管理、费用控制、限流、超时和运行监控。示例通过环境变量注入 API 密钥,避免敏感配置进入版本库。
完整中文译文
在笔记本上运行一个 AI Agent 是一回事,把它交给真实用户使用又是另一回事。Agent 本身没变,但它周围的一切都变了。
API key 不能放在代码里。不盯着 token 用量,费用很快就会涨上去。测试时还能接受的慢速模型调用,到了生产环境就可能超时。出了问题,你还得有能真正说明发生了什么的日志。
这篇文章讨论把 Spring AI Agent 部署上线时的实际问题:密钥管理、成本意识、限流、超时,以及上线后需要监控什么。
开发时,你可能图方便,直接把 API key 写进 application.properties:
spring.ai.openai.api-key=sk-proj-abc123...
别这么做。这个文件会被提交到版本控制系统,密钥随之泄露,最后账单找上门。
正确的做法是从环境变量读取密钥。
spring.ai.openai.api-key=${OPENAI_API_KEY}
然后,先在环境中设置密钥,再启动应用:
export OPENAI_API_KEY=sk-proj-abc123...
java -jar your-app.jar
在云平台上,比如 Railway、Render、Fly.io、AWS,应在部署设置中将它配置为秘密环境变量,绝不要写进会被推送的配置文件。
本地开发怎么办?使用已加入 .gitignore 的 .env 文件,通过 direnv 之类的工具加载,或者直接在 shell 的配置文件中设置变量。绝不要提交 .env。
按环境拆分配置
应用在开发环境和生产环境中需要不同的设置:
开发环境:详细日志、较短的超时时间,以及较小的 token 上限,避免测试时耗尽配额。
生产环境:更严格的超时时间、更高的 token 上限、结构化日志。
Spring Boot 的 profile 机制可以清晰地处理这些差异。
src/main/resources/application.properties——公共默认配置:
spring.ai.openai.api-key=${OPENAI_API_KEY}
app.agent.timeout-seconds=30
app.agent.max-tokens=2000
src/main/resources/application-dev.properties——开发环境覆盖配置:
logging.level.com.example=DEBUG
app.agent.timeout-seconds=60
app.agent.max-tokens=500
src/main/resources/application-prod.properties——生产环境覆盖配置:
logging.level.com.example=INFO
app.agent.timeout-seconds=20
app.agent.max-tokens=4000
启动时激活指定的 profile:
java -jar your-app.jar --spring.profiles.active=prod
Agent 代码直接读取这些值,不需要知道当前激活的是哪个 profile:
@Value("${app.agent.timeout-seconds}")
private int timeoutSeconds;
@Value("${app.agent.max-tokens}")
private int maxTokens;
超时:每一层都要设置
开发时,模型调用慢一点只是让人心烦。到了生产环境,它会阻塞线程、占用连接,甚至引发连锁故障,导致整个服务不可用。
每一层都要设置超时。
在 ChatClient 调用上设置:
String response = CompletableFuture
.supplyAsync(() -> chatClient.prompt()
.user(request.message())
.tools(travelTools)
.call()
.content())
.get(timeoutSeconds, TimeUnit.SECONDS);
对于 Spring AI 内部使用的 HTTP client,可以通过配置属性设置:
spring.ai.openai.chat.options.timeout=20000
在使用 Spring MVC 的 REST 端点上设置:
@PostMapping("/chat")
@ResponseTimeout(20000)
public ResponseEntity<String> chat(@RequestBody ChatRequest request) {
// ...
}
选定一个统一的超时时间,在各处保持一致。对于大多数 Agent 调用,30 秒是一个合理的上限。如果你的 Agent 需要更长时间,很可能是单个请求承担了太多工作,可以考虑拆成更小的步骤。
Token 成本:你究竟在为什么付费
每次调用模型都会按 token 收费,每个 token 大约相当于四分之三个英文单词。费用来自两部分:
输入 token:system prompt + 对话历史 + 用户消息 + 工具定义。
输出 token:模型的回复。
输出 token 比输入 token 更贵。一段冗长的 system prompt 如果占用 500 个 token,每次请求都会产生这笔消耗。再乘以 10,000 个用户,成本就相当可观了。
Token 成本藏在哪里:
如何在 Spring AI 中跟踪用量:
每个 ChatResponse 都包含用量元数据:
ChatResponse chatResponse = chatClient.prompt()
.user(request.message())
.call()
.chatResponse();
Usage usage = chatResponse.getMetadata().getUsage();
log.info("tokens — input: {}, output: {}, total: {}",
usage.getPromptTokens(),
usage.getGenerationTokens(),
usage.getTotalTokens());
生产环境的每次请求都要记录这些数据。几天之后,你就能清楚地看到成本来自哪里,以及它的增长是否符合预期。
限流:保护自己,也保护 API
模型提供商的 API 有速率限制,包括每分钟请求数和每分钟 token 数。触及限制,应用就会收到 429 错误。
需要解决两个问题:
1. 防止单个用户高频调用 API
为每个用户使用一个简单的内存限流器;如果没有用户身份信息,就按 IP 限流:
@Component
public class RateLimiter {
private final Map<String, AtomicInteger> callCount = new ConcurrentHashMap<>();
private final Map<String, Long> windowStart = new ConcurrentHashMap<>();
private static final int MAX_CALLS_PER_MINUTE = 10;
public boolean allow(String userId) {
long now = System.currentTimeMillis();
long start = windowStart.computeIfAbsent(userId, k -> now);
if (now - start > 60_000) {
callCount.put(userId, new AtomicInteger(0));
windowStart.put(userId, now);
}
return callCount.computeIfAbsent(userId, k -> new AtomicInteger(0))
.incrementAndGet() <= MAX_CALLS_PER_MINUTE;
}
}
在 controller 中检查:
if (!rateLimiter.allow(request.userId())) {
return ResponseEntity.status(429)
.body("Too many requests. Please wait a moment.");
}
2. 妥善处理提供商返回的 429
try {
return chatClient.prompt().user(message).call().content();
} catch (OpenAiHttpException e) {
if (e.statusCode() == 429) {
return "The service is busy right now. Please try again in a moment.";
}
throw e;
}
绝不要让未经处理的 429 最后以 500 错误的形式呈现给用户。
退避重试:应对临时故障
模型 API 会出现临时故障,比如短暂的网络抖动、偶发的 500 错误。稍等片刻后重试一次,就能处理其中的大多数情况。
使用 Spring Retry 可以很方便地实现:
<dependency>
<groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId>
</dependency>
在主类上启用:
@EnableRetry
@SpringBootApplication
public class Application { ... }
为 Agent 调用添加重试:
@Retryable(
retryFor = { OpenAiHttpException.class },
maxAttempts = 3,
backoff = @Backoff(delay = 1000, multiplier = 2)
)
public String callAgent(String message) {
return chatClient.prompt().user(message).call().content();
}
这段配置最多尝试 3 次,两次尝试之间分别等待 1 秒、2 秒。它只针对 OpenAiHttpException 重试,不会重试你自己的应用错误。
不要在遇到 429 错误后短暂等待就重试,这只会让你更快触及速率限制。只对 500 错误和网络错误进行重试。
生产环境该记录什么日志
好的生产日志应该能回答:“Agent 为谁做了什么,结果怎么样?”
每次 Agent 请求至少应记录以下字段:
@PostMapping("/chat")
public ResponseEntity<String> chat(@RequestBody ChatRequest request) {
String requestId = UUID.randomUUID().toString();
long start = System.currentTimeMillis();
log.info("[req:{}] agent call — userId={} conversationId={}",
requestId, request.userId(), request.conversationId());
try {
String response = callAgent(request.message(), request.conversationId());
long durationMs = System.currentTimeMillis() - start;
log.info("[req:{}] agent complete — durationMs={} responseLength={}",
requestId, durationMs, response.length());
return ResponseEntity.ok(response);
} catch (TimeoutException e) {
log.warn("[req:{}] agent timeout after {}ms", requestId, timeoutSeconds * 1000);
return ResponseEntity.status(504).body("Request timed out.");
} catch (Exception e) {
log.error("[req:{}] agent error — {}", requestId, e.getMessage(), e);
return ResponseEntity.status(500).body("Something went wrong.");
}
}
requestId 将同一个请求的所有日志关联起来。durationMs 能帮助你判断调用是否在逐渐变慢。错误处理分支始终记录完整的堆栈信息。
健康检查与就绪状态
请求到达 Agent 之前,你需要知道应用是否健康。Spring Boot Actuator 开箱即用地提供了这项能力:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
management.endpoints.web.exposure.include=health,info
management.endpoint.health.show-details=when-authorized
应用运行时,GET /actuator/health 会返回 {"status":"UP"}。部署平台,比如 Render、Railway、Kubernetes,会探测这个端点;如果返回的状态不是 UP,就会停止向该实例转发流量。
添加一个自定义健康检查,确认模型 API 可以访问:
@Component
public class ModelApiHealthIndicator implements HealthIndicator {
private final ChatModel chatModel;
public ModelApiHealthIndicator(ChatModel chatModel) {
this.chatModel = chatModel;
}
@Override
public Health health() {
try {
chatModel.call(new Prompt("ping"));
return Health.up().build();
} catch (Exception e) {
return Health.down()
.withDetail("reason", e.getMessage())
.build();
}
}
}
如果模型 API 不可用,健康检查会返回 DOWN,平台就可以将流量绕开故障实例,或者向你发送告警。
部署检查清单
- [ ] API key 保存在环境变量中,没有写入任何已提交的文件
- [ ] .env 已加入 .gitignore
- [ ] 日志输出中没有任何密钥
- [ ] 每次 Agent 调用都设置了超时
- [ ] 已捕获 429 错误,并返回易于理解的提示
- [ ] 临时错误配置了退避重试
- [ ] 每次请求都记录 token 用量
- [ ] system prompt 尽可能简洁
- [ ] 对话历史窗口有明确上限(chatHistoryWindowSize)
- [ ] 每次请求都有唯一的请求 ID
- [ ] 每次请求都记录耗时
- [ ] 错误日志包含完整的堆栈信息
- [ ] 已配置健康检查端点
- [ ] 日志发送到了你确实能查看的地方,而不只是输出到一台无法通过 SSH 登录的服务器的 stdout
Agent 已经部署上线,开始处理真实流量。接下来的问题是规模:当大量用户同时访问 Agent、对话历史越来越长,以及需要在不停机的情况下更新模型或工具时,会发生什么?
Sham Prakash K——后端工程师,在 Java、Spring Boot 和分布式系统领域拥有 4 年以上经验。目前正在构建 AI 后端基础设施,分享自己实际学到的东西,也包括犯过的错。
如需进一步处理,你可以考虑屏蔽此人和/或举报滥用行为。