在 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 和模型名称即可。
| 依赖项 | 要求 | 说明 |
|---|---|---|
| JDK | 17 或更高 | Spring Boot 3 的基础要求 |
| Maven | 3.8+ | 用于依赖管理 |
| Spring Boot | 3.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> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.3.5</version> <relativePath/> </parent> <properties> <java.version>17</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</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) { List<Map<String, 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)); Map<String, 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( List<Choice> 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使用List<Map<String, 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 &&