大模型工程化实践:用Spring Boot给AI调用加预算与安全减速带
在 AI Agent 和大模型应用中提到 p(doom) 时很多人首先想到的是“未来通用 AI 会不会失控”这类宏大概率。但对于正在把大模型接入业务系统的工程师来说p(doom) 更需要被翻译成一个工程问题模型进入真实链路后产生不可控、不可用、不可追责输出的概率有多大。这个概率才是可以通过代码、配置和流程去降低的。如果把标题里的 Decelerate AI 理解为“在模型和业务结果之间增加减速带”把 “using Capitalism Itself” 理解为“用调用预算、成本核算、责任归因和止损机制去约束模型行为”整个话题就变成了一条完整的 AI 工程实践主线。本文会用 Spring Boot 实现一个带预算限制、输入检查、输出校验、审计日志和人工审批位的受控模型调用入口用真实可运行的代码说明如何让一次模型调用从“裸奔”变为“有流程可追责”。1. 先理清p(doom) 在应用层指什么减速带应该加在哪里1.1 从末日概率到线上事故概率p(doom) 本质是一种对未来结果的概率估计。对通用 AI 的末日预测很难验证但应用层的风险却可以被定义成非常具体的指标例如用户通过提示词注入让模型绕过系统规则。模型把内部信息、密钥或不在授权范围内的内容输出给请求方。模型生成了超出业务边界的建议例如误导性的代码、医疗结论或财务操作。高并发调用导致模型费用失控。调用过程没有日志出事之后无法归因。这些风险发生一次对团队来说就像一次真实的 “small doom”。它们的共同点是当模型被直接暴露给用户时风险几乎无法被提前拦截。模型是一个概率系统不能只靠“告诉它不要做什么”来保证安全。要降低事故概率就必须在模型入口和出口上插入确定性的规则让每一次调用都先经过检查再执行先经过预算核算再把输出返回给用户。1.2 用预算、成本和责任链给模型调用装上刹车为什么企业内的 AI 应用需要按“成本机制”来控制模型行为而不是只依靠提示词约束原因是成本机制能够产生真实的可执行反馈。模型每次调用都产生成本公司为这个成本设置了预算团队就必须关心请求是否合理模型输出引发事故责任链就会出现明确归属团队就必须补充规则一旦调用量触发熔断上线流程就必须先止血而不是继续无限放量。把这个机制落到代码里就是三个动作记录每次调用的 Token 消耗并把它换算成费用。设置单次请求费用上限和每日总费用预算超过阈值立即熔断。每一次调用都记录用户、请求内容、模型输出、拦截结果、费用和告警原因。这三个动作构成了一个可核算的闭环。模型输出不再只是“模型说了什么”而是一笔带责任的业务操作。这也是标题里 “by using Capitalism Itself” 在工程中最朴素的落地方式有成本就有取舍有责任就有保护。1.3 一条受控调用链路常见的四类减速带在系统链路中“减速带”不是某一个过滤器而是一组位于模型前后的规则和决策点。下面这张表可以作为设计模型入口时的检查清单。减速带典型机制解决的典型问题可观察指标输入护栏长度限制、敏感信息检查、系统提示注入检查、URL 白名单异常数据进入模型输入拦截次数预算护栏Token 估算、费用统计、单次限额、每日熔断模型费用失控今日成本、熔断次数输出护栏格式校验、敏感词检测、长度限制、结构化输出约束违规内容离开模型输出拦截次数人工决策高风险动作进入待审批状态由业务人员确认后执行模型无法为高风险操作负责待审批数量、审批通过率审计日志记录请求、响应、拦截原因、调用方、费用出现事故无法回溯完整调用链条这里要强调一个原则模型本身的判断是概率性的不能作为唯一防线。真正可靠的安全兜底应该由确定性代码完成例如长度检查、格式解析、预算判断、权限校验。模型能力适合做内容生成、意图分类这类开放问题不适合做“是否允许通过”的最终决策。2. 准备工程环境搭建一个可复现的最小模型调用服务2.1 环境准备为了让示例可以离线运行这里采用本地可部署的 OpenAI 兼容模型服务。如果你已经在使用云厂商的大模型接口只需要替换 base-url 和模型名称即可。依赖项要求说明JDK17 或更高Spring Boot 3 的基础要求Maven3.8用于依赖管理Spring Boot3.3.x示例使用 3.3.5本地模型服务Ollama 或者其他 OpenAI 兼容服务示例使用 Ollama 默认地址http://localhost:11434模型名称qwen2.5:7b可按需替换为其他兼容模型前置知识需要具备 Spring Boot 基础、Maven 项目结构以及 REST API 的基本概念。真实项目接入商业大模型时要按供应商文档核对接口路径、鉴权方式和 token 计费规则。2.2 初始化 Maven 项目创建一个新的 Maven 项目包名使用com.example.aigateway。下面是 pom.xml 的核心配置。parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.3.5/version relativePath/ /parent properties java.version17/java.version /properties dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope /dependency /dependencies代码中不引入具体大模型的 SDK而是通过 Spring 的RestClient直接调用 OpenAI 兼容接口。这样做的原因是依赖面更小后续无论是切换到本地 Ollama、内部 vLLM 服务还是云厂商兼容网关都只需要改配置。2.3 配置文件与模型参数在src/main/resources/application.yml中设置模型服务地址、模型名称和预算相关参数。server: port: 8080 ai: gateway: base-url: http://localhost:11434 model: qwen2.5:7b api-key: ${LLM_API_KEY:} max-cost-per-request: 0.02 daily-budget: 0.5 pricing: input-per-million: 1.2 output-per-million: 3.0注意以下几点api-key通过环境变量注入仓库中不提交真实密钥。本地 Ollama 通常不需要 api-key接入商业模型时保留该字段。daily-budget示例中设置 0.5 美元是为了方便演示熔断真实项目需要按业务预算调整。价格参数只是演示用实际接入时必须改成模型供应商的计费单价。为了让配置能绑定到 Java 对象编写一个AiGatewayProperties类。package com.example.aigateway; import org.springframework.boot.context.properties.ConfigurationProperties; ConfigurationProperties(prefix ai.gateway) public record AiGatewayProperties( String baseUrl, String model, String apiKey, double maxCostPerRequest, double dailyBudget, Pricing pricing ) { public record Pricing(double inputPerMillion, double outputPerMillion) { } }在启动类上开启配置绑定。package com.example.aigateway; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.EnableConfigurationProperties; SpringBootApplication EnableConfigurationProperties(AiGatewayProperties.class) public class AiGatewayApplication { public static void main(String[] args) { SpringApplication.run(AiGatewayApplication.class, args); } }2.4 先实现一个能工作的模型客户端为了先验证模型链路是否通畅写一个最小的ModelClient它接收用户消息和一个可选的系统提示词然后调用 OpenAI 兼容的 chat completions 接口。package com.example.aigateway; import com.fasterxml.jackson.annotation.JsonProperty; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import org.springframework.http.MediaType; import org.springframework.stereotype.Service; import org.springframework.web.client.RestClient; Service public class ModelClient { private final AiGatewayProperties properties; private final RestClient restClient; public ModelClient(AiGatewayProperties properties) { this.properties properties; RestClient.Builder builder RestClient.builder() .baseUrl(properties.baseUrl()); if (properties.apiKey() ! null !properties.apiKey().isBlank()) { builder.defaultHeader(Authorization, Bearer properties.apiKey()); } this.restClient builder.build(); } public ChatResult complete(String userMessage, String systemMessage) { ListMapString, String messages new ArrayList(); if (systemMessage ! null !systemMessage.isBlank()) { messages.add(Map.of(role, system, content, systemMessage)); } messages.add(Map.of(role, user, content, userMessage)); MapString, Object body new HashMap(); body.put(model, properties.model()); body.put(messages, messages); body.put(stream, false); body.put(temperature, 0.2); ChatCompletionResponse response restClient.post() .uri(/v1/chat/completions) .contentType(MediaType.APPLICATION_JSON) .body(body) .retrieve() .body(ChatCompletionResponse.class); if (response null || response.choices() null || response.choices().isEmpty()) { throw new IllegalStateException(模型没有返回任何内容); } return new ChatResult( response.choices().get(0).message().content(), response.usage() null ? 0 : response.usage().promptTokens(), response.usage() null ? 0 : response.usage().completionTokens(), response.usage() null ? 0 : response.usage().totalTokens() ); } public ChatResult complete(String userMessage) { return complete(userMessage, null); } public record ChatResult( String content, int promptTokens, int completionTokens, int totalTokens ) { } public record ChatCompletionResponse( ListChoice choices, Usage usage ) { public record Choice(Message message) { } public record Message(String role, String content) { } public record Usage( JsonProperty(prompt_tokens) int promptTokens, JsonProperty(completion_tokens) int completionTokens, JsonProperty(total_tokens) int totalTokens ) { } } }这段代码有两点需要说明messages使用ListMapString, String构建便于适配多种模型服务。响应体的usage中包含 token 消耗预算系统要靠它计算成本。2.5 不接护栏时的一个裸调用新增一个直接调用接口用于演示没有护栏时的样子。package com.example.aigateway; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; Validated RestController public class DirectChatController { private final ModelClient modelClient; public DirectChatController(ModelClient modelClient) { this.modelClient modelClient; } PostMapping(/direct/chat) public ModelClient.ChatResult direct(Valid RequestBody PromptRequest request) { return modelClient.complete(request.prompt()); } public record PromptRequest( NotBlank String prompt ) { } }启动本地模型服务后运行项目。ollama pull qwen2.5:7b mvn spring-boot:run在另一个终端发送请求。curl -s http://localhost:8080/direct/chat \ -H Content-Type: application/json \ -d {prompt:用一句话介绍 Spring Boot}会得到一个类似下面的响应。{ content: Spring Boot 是一个用于简化 Spring 应用初始搭建和开发过程的框架。, promptTokens: 18, completionTokens: 26, totalTokens: 44 }这个接口能工作但存在几个明显问题没有区分调用方身份无法做权限控制。没有记录日志调用失败了也无法排查。没有预算限制模型可以无限调用。请求内容没有校验用户可以直接尝试提示词注入。输出没有检查模型返回敏感内容也无法拦截。后面所有章节都会围绕这些问题逐步补上减速带。3. 实现带预算和审计的受控调用入口3.1 用 Token 用量把成本变成可监控的数字大模型服务的费用通常由输入 token 和输出 token 共同决定。ModelClient返回的ChatResult已经包含 token 消耗接下来把它换算成费用。package com.example.aigateway; import org.springframework.stereotype.Component; Component public class CostCalculator { private final AiGatewayProperties properties; public CostCalculator(AiGatewayProperties properties) { this.properties properties; } public double calculate(ModelClient.ChatResult result) { AiGatewayProperties.Pricing pricing properties.pricing(); double inputCost result.promptTokens() / 1_000_000.0 * pricing.inputPerMillion(); double outputCost result.completionTokens() / 1_000_000.0 * pricing.outputPerMillion(); return inputCost outputCost; } }这里的费用计算使用每百万 token 单价。真实项目中如果模型供应商按缓存命中、batch 等不同维度计费还需要扩展计价规则。3.2 用预算管理器控制今日总费用预算管理器需要支持两个能力单次请求费用是否超过maxCostPerRequest。当日累计费用是否超过dailyBudget。为了简单这里使用内存中的synchronized方法做累计。生产环境应当把预算数据放到 Redis 或数据库中避免多实例下计数不准确。package com.example.aigateway; import org.springframework.stereotype.Component; Component public class BudgetManager { private final AiGatewayProperties properties; private final Object lock new Object(); private double spentToday 0.0; public BudgetManager(AiGatewayProperties properties) { this.properties properties; } public void checkBeforeRequest() { synchronized (lock) { if (spentToday properties.dailyBudget()) { throw new BudgetExceededException(DAILY_BUDGET_EXCEEDED, 今日模型调用预算已用完); } } } public void recordCost(double cost) { synchronized (lock) { if (cost properties.maxCostPerRequest()) { throw new BudgetExceededException( REQUEST_COST_EXCEEDED, 单次请求费用超过上限: cost ); } if (spentToday cost properties.dailyBudget()) { throw new BudgetExceededException( DAILY_BUDGET_EXCEEDED, 剩余预算不足以完成本次调用 ); } spentToday cost; } } public double spentToday() { synchronized (lock) { return spentToday; } } }单独定义预算异常避免业务方把底层异常直接抛出。package com.example.aigateway; public class BudgetExceededException extends RuntimeException { private final String code; public BudgetExceededException(String code, String message) { super(message); this.code code; } public String getCode() { return code; } }这里有一个取舍需要注意真正调用模型之前无法精确知道会用多少 token所以recordCost放在模型返回之后执行。如果某一次请求消耗过大输出虽然已经生成但会被后续逻辑拦截不会直接回到用户手里。这能阻止费用继续扩大但无法避免这一次超支。要更早拦截可以引入 prompt tokenizer 做预估算但会增加复杂度小型项目可以先不追求精确预估。3.3 不让 Controller 直接碰模型客户端裸调用的问题在于 Controller 直接依赖ModelClient任何校验都容易被跳过。受控入口应该由ControlledChatService统一处理Controller 只负责接收 HTTP 请求和返回响应。package com.example.aigateway; import org.springframework.stereotype.Service; Service public class ControlledChatService { private static final String DEFAULT_SYSTEM_PROMPT 你是一个受内部规则约束的助手。不要生成违反法律法规的内容 不要输出明显越权的操作建议回答尽量简洁。; private final ModelClient modelClient; private final BudgetManager budgetManager; private final CostCalculator costCalculator; private final AuditLogger auditLogger; private final InputGuard inputGuard; private final OutputGuard outputGuard; public ControlledChatService( ModelClient modelClient, BudgetManager budgetManager, CostCalculator costCalculator, AuditLogger auditLogger, InputGuard inputGuard, OutputGuard outputGuard ) { this.modelClient modelClient; this.budgetManager budgetManager; this.costCalculator costCalculator; this.auditLogger auditLogger; this.inputGuard inputGuard; this.outputGuard outputGuard; } public ModelClient.ChatResult chat(String userId, String prompt) { budgetManager.checkBeforeRequest(); inputGuard.validate(prompt); auditLogger.record(new AuditLogger.AuditEvent( userId, CHAT_START, prompt, null, 0.0 )); ModelClient.ChatResult result modelClient.complete( prompt, DEFAULT_SYSTEM_PROMPT ); double cost costCalculator.calculate(result); budgetManager.recordCost(cost); outputGuard.validate(result.content()); auditLogger.record(new AuditLogger.AuditEvent( userId, CHAT_SUCCESS, prompt, result.content(), cost )); return result; } }这个 Service 定义了当前受控调用链路的顺序先检查预算余量。进入输入护栏。记录开始日志。调用模型。计算成本并记录到预算。进入输出护栏。记录成功日志。如果第 2、5、6 步任意一个失败日志仍然会记录一次失败动作而不是让请求静默消失。3.4 审计日志先落本地生产环境再替换数据库短时间内用一个简单的文件追加方式记录审计日志。生产环境应该使用数据库或消息队列但文件采集日志的方式足够用来验证链路。package com.example.aigateway; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.BufferedWriter; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; import java.time.Instant; import org.springframework.stereotype.Component; Component public class AuditLogger { private final ObjectMapper objectMapper new ObjectMapper(); private final Path path Path.of(logs, ai-audit.jsonl); public void record(AuditEvent event) { try { Files.createDirectories(path.getParent()); String line objectMapper.writeValueAsString(event) System.lineSeparator(); try (BufferedWriter writer Files.newBufferedWriter( path, StandardOpenOption.CREATE, StandardOpenOption.APPEND )) { writer.write(line); } } catch (Exception ex) { throw new IllegalStateException(写入审计日志失败, ex); } } public record AuditEvent( String userId, String action, String prompt, String response, double cost ) { public AuditEvent { if (prompt ! null