很多模型接入项目把主要精力放在"能否得到回复",Controller 最终只剩一个接收 Map<String, Object> 的接口。这样虽然开发快,却会把若干问题推迟到生产环境:客户端拼错字段时被静默忽略,空消息进入模型调用,数百条历史消息撑大请求体,非法角色绕过业务约束,上游错误又被统一包装成含义模糊的 HTTP 500。
模型接口尤其需要严格的入参边界。它通常连接按量计费或有配额限制的外部服务,输入还可能包含隐私数据。参数校验不能解决提示词注入,也不能代替内容合规审查,但它至少能在发起上游请求之前完成结构、长度、枚举值和业务关系检查,减少无效调用,并为调用方提供稳定的错误契约。
本文使用 Spring Boot 3.x 风格的 jakarta.validation API 展示实现。若项目仍使用 Spring Boot 2.x,需要将相关导入改为 javax.validation,并依据项目实际依赖调整配置;不要仅复制包名后假定行为完全相同。
请求进入 Controller 前发生了什么
对于带有 @RequestBody 的 JSON 请求,典型处理链可以简化为四步:
- Spring 根据
Content-Type选择HttpMessageConverter。 - Jackson 将请求体反序列化为 Java DTO;类型错误、非法枚举或 JSON 语法错误会在这一阶段失败。
@Valid或@Validated触发 Bean Validation,检查长度、空值及嵌套对象约束。- Controller 执行业务级校验,然后调用模型适配器。
这几个阶段产生的异常并不相同。JSON 无法解析通常对应 HttpMessageNotReadableException,Bean Validation 失败对应 MethodArgumentNotValidException。如果不做统一处理,调用方很难获得可预测的错误格式。
另一个容易忽略的边界是"未知字段"。默认配置可能允许客户端提交 DTO 中不存在的属性。例如调用方误写 maxToken,服务端可能忽略它并采用默认值。对于公开 API,更稳妥的做法通常是拒绝未知字段,让契约漂移尽早暴露。
第一步:准备依赖与严格反序列化配置
MVC 服务需要 Web、Validation 依赖;示例使用 WebClient 调用上游,因此再加入 WebFlux 依赖,但服务端仍可继续采用 Spring MVC:
xml
<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-webflux</artifactId>
</dependency>
在 application.yml 中拒绝未知 JSON 字段,并把模型地址、模型名和密钥全部放到环境变量:
yaml
spring:
jackson:
deserialization:
fail-on-unknown-properties: true
model:
base-url: ${MODEL_BASE_URL}
api-key: ${MODEL_API_KEY}
name: ${MODEL_NAME}
不要允许客户端直接提交 base-url。否则服务端可能被利用去访问内网地址,形成 SSRF 风险。上游地址应由部署配置确定,客户端只提交业务参数。
如果团队正在评估中转接口,可以把 HaerAPI 作为候选接入端之一,但端点路径、鉴权头、请求字段和响应格式必须以其当前文档为准;下面的适配器只适用于明确提供相应兼容契约的服务,不能仅凭域名替换就认定可用。
第二步:用 DTO 表达输入契约
不要让外部请求直接绑定到数据库实体,也不要用无约束的 Map 承载核心字段。可以把接口输入定义为不可变 record:
java
package com.example.chat.api;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import java.util.List;
public record ChatRequest(
@NotEmpty
@Size(max = 30)
List<@Valid MessageInput> messages,
@Min(1)
@Max(2048)
Integer maxTokens
) {
public record MessageInput(
@jakarta.validation.constraints.NotNull Role role,
@NotBlank
@Size(max = 8000)
String content
) {}
public enum Role {
USER, ASSISTANT
}
}
这里有几个有意设置的边界:
- 角色使用枚举,不接受任意字符串。
messages同时限制非空、最大条数,并通过List<@Valid MessageInput>校验每个元素。- 单条内容和输出 token 参数都有上限。
- 不允许外部调用方提交
SYSTEM角色,系统提示词由服务端控制。
具体数值不是通用标准,应结合上游上下文限制、产品需求和成本预算制定。字符数也不等于 token 数,因此 @Size 只能作为入口保护;若必须严格控制上下文窗口,还需要使用与目标模型匹配的 tokenizer 或采用保守预算。
第三步:在 Controller 中补充业务关系校验
声明式注解适合检查单字段,跨字段规则则应放到清晰的业务方法中。例如最后一条消息必须来自用户:
java
package com.example.chat.api;
import jakarta.validation.Valid;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/chat")
public class ChatController {
private final ModelClient modelClient;
public ChatController(ModelClient modelClient) {
this.modelClient = modelClient;
}
@PostMapping(
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE
)
public ChatResponse chat(@Valid @RequestBody ChatRequest request) {
var last = request.messages().get(request.messages().size() - 1);
if (last.role() != ChatRequest.Role.USER) {
throw new RequestRuleException("最后一条消息必须来自 USER");
}
return modelClient.complete(request);
}
}
RequestRuleException 是项目自己的运行时异常:
java
public class RequestRuleException extends RuntimeException {
public RequestRuleException(String message) {
super(message);
}
}
不要把供应商密钥、系统提示词或内部模型路由参数放进 ChatRequest。即使前端页面没有展示这些字段,攻击者仍可自行构造 HTTP 请求。
第四步:隔离上游模型协议
Controller 不应了解供应商的 URL 和响应结构。下面给出一个针对常见聊天补全 JSON 形状的最小适配器。只有当所选服务当前文档明确支持这些字段时才能直接使用,否则应修改映射层:
java
package com.example.chat.api;
import com.fasterxml.jackson.databind.JsonNode;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
@Component
public class ModelClient {
private final WebClient client;
private final String modelName;
public ModelClient(
WebClient.Builder builder,
@Value("${model.base-url}") String baseUrl,
@Value("${model.api-key}") String apiKey,
@Value("${model.name}") String modelName
) {
this.client = builder
.baseUrl(baseUrl)
.defaultHeaders(h -> h.setBearerAuth(apiKey))
.build();
this.modelName = modelName;
}
public ChatResponse complete(ChatRequest input) {
List<Map<String, String>> messages = input.messages().stream()
.map(m -> Map.of(
"role", m.role().name().toLowerCase(),
"content", m.content()))
.toList();
Map<String, Object> body = Map.of(
"model", modelName,
"messages", messages,
"max_tokens", input.maxTokens());
JsonNode json = client.post()
.uri("/v1/chat/completions")
.bodyValue(body)
.retrieve()
.bodyToMono(JsonNode.class)
.block();
JsonNode content = json == null
? null
: json.at("/choices/0/message/content");
if (content == null || !content.isTextual()) {
throw new UpstreamProtocolException("上游响应缺少文本结果");
}
return new ChatResponse(content.asText());
}
}
record ChatResponse(String content) {}
为了突出绑定逻辑,代码没有展开超时、重试和流式响应。生产实现至少应设置连接与响应超时,并区分上游 4xx、429、5xx、超时和协议错误。重试只能用于满足幂等条件的场景;模型请求是否会重复计费、是否支持幂等键,应依据具体服务文档确认,不能默认所有失败都可自动重试。
第五步:建立稳定的错误响应
可以用 ProblemDetail 输出统一的 application/problem+json 结构:
java
package com.example.chat.api;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class ApiExceptionHandler {
record FieldIssue(String field, String message) {}
@ExceptionHandler(MethodArgumentNotValidException.class)
ProblemDetail validation(MethodArgumentNotValidException ex) {
ProblemDetail detail = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
detail.setTitle("请求参数校验失败");
List<FieldIssue> issues = ex.getBindingResult().getFieldErrors().stream()
.map(e -> new FieldIssue(e.getField(), e.getDefaultMessage()))
.toList();
detail.setProperty("errors", issues);
return detail;
}
@ExceptionHandler(HttpMessageNotReadableException.class)
ProblemDetail unreadable() {
ProblemDetail detail = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
detail.setTitle("请求体无法解析");
detail.setDetail("请检查 JSON 语法、字段名称、数据类型和枚举值");
return detail;
}
@ExceptionHandler(RequestRuleException.class)
ProblemDetail rule(RequestRuleException ex) {
ProblemDetail detail = ProblemDetail.forStatus(HttpStatus.UNPROCESSABLE_ENTITY);
detail.setTitle("请求不满足业务规则");
detail.setDetail(ex.getMessage());
return detail;
}
}
错误响应不应包含完整上游响应、堆栈、密钥或用户提示词。服务端日志也应避免原样记录消息正文;确需排查时,可记录请求 ID、消息条数、长度、模型路由和脱敏后的错误类别。
第六步:执行验证
启动前设置环境变量,值应来自实际供应商文档和团队的密钥管理系统:
bash
export MODEL_BASE_URL='https://provider.example'
export MODEL_API_KEY='replace-with-secret-from-secret-manager'
export MODEL_NAME='model-id-from-provider-docs'
./mvnw spring-boot:run
发送一个结构正确的请求:
bash
curl -i http://localhost:8080/api/chat \
-H 'Content-Type: application/json' \
-d '{"messages":[{"role":"USER","content":"解释乐观锁的适用边界"}],"maxTokens":512}'
随后至少验证四类失败:提交不存在的字段、把 role 改成非法值、传入空白 content、提交超过 30 条消息。它们都应在调用上游之前失败。可以在 ModelClient 上使用 MockWebServer 或 WireMock 编写测试,断言无效输入不会产生上游 HTTP 请求,避免测试依赖真实模型服务和真实密钥。
网关层还应限制请求体大小。例如 Nginx 可以设置:
nginx
location /api/chat {
client_max_body_size 256k;
proxy_pass http://spring_app;
}
该限制需要与业务允许的消息长度协调;过小会拒绝合法请求,过大则不能形成有效保护。应用层约束与代理层大小限制应同时存在,因为二者解决的问题不同。
常见问题
使用 DTO 后是否就不会发生提示词注入?
不会。DTO 校验保证的是结构和基础业务约束,无法判断自然语言是否试图覆盖系统指令。系统提示词隔离、工具白名单、输出校验、最小权限和人工审核仍需单独设计。
为什么不直接返回校验异常的原始消息?
原始异常可能暴露 Java 类型、内部字段或解析细节,而且不同依赖版本的文本不适合作为公开契约。应由服务端输出稳定的错误码、字段路径和可理解的说明,同时把详细堆栈留在受控日志中。
maxTokens 允许客户端控制安全吗?
可以允许,但必须设置服务端硬上限。更严格的系统还可按租户、接口或模型设置不同预算。最终传给上游的值应取用户请求、服务端策略和模型限制三者允许范围的交集。
是否应该自动裁剪过长历史消息?
取决于产品契约。静默裁剪可能丢失关键上下文;直接拒绝更可预测,但会增加客户端处理成本。若采用裁剪,应明确保留规则、记录被裁剪的消息范围,并在响应元数据中告知调用方。
为什么未知字段要报错?
公开接口中,拒绝未知字段有助于发现拼写错误和客户端版本漂移。但在需要渐进兼容的内部系统中,也可能选择容忍新增字段。无论采用哪种策略,都应通过契约测试固定下来,避免升级 Jackson 或调整 DTO 时无意改变行为。
总结
可靠的模型接口不是把 JSON 转发给上游,而是建立清晰的输入边界:用 DTO 表达结构,用 Bean Validation 检查基础约束,用业务代码处理跨字段规则,用统一错误格式稳定客户端预期,再通过独立适配器隔离不同模型协议。
参数绑定只是第一道门。请求体大小、日志脱敏、固定上游地址、服务端 token 预算、超时、错误分层和契约测试共同构成可维护的接入基础。先让非法请求在本地、可解释地失败,再讨论重试、流式输出和多供应商路由,系统的成本与安全边界才不会被一个宽松的 Controller 悄悄绕开。