别让模型接口成为参数黑洞:Spring MVC 请求绑定、校验与错误契约实战

很多模型接入项目把主要精力放在"能否得到回复",Controller 最终只剩一个接收 Map<String, Object> 的接口。这样虽然开发快,却会把若干问题推迟到生产环境:客户端拼错字段时被静默忽略,空消息进入模型调用,数百条历史消息撑大请求体,非法角色绕过业务约束,上游错误又被统一包装成含义模糊的 HTTP 500。

模型接口尤其需要严格的入参边界。它通常连接按量计费或有配额限制的外部服务,输入还可能包含隐私数据。参数校验不能解决提示词注入,也不能代替内容合规审查,但它至少能在发起上游请求之前完成结构、长度、枚举值和业务关系检查,减少无效调用,并为调用方提供稳定的错误契约。

本文使用 Spring Boot 3.x 风格的 jakarta.validation API 展示实现。若项目仍使用 Spring Boot 2.x,需要将相关导入改为 javax.validation,并依据项目实际依赖调整配置;不要仅复制包名后假定行为完全相同。

请求进入 Controller 前发生了什么

对于带有 @RequestBody 的 JSON 请求,典型处理链可以简化为四步:

  1. Spring 根据 Content-Type 选择 HttpMessageConverter
  2. Jackson 将请求体反序列化为 Java DTO;类型错误、非法枚举或 JSON 语法错误会在这一阶段失败。
  3. @Valid@Validated 触发 Bean Validation,检查长度、空值及嵌套对象约束。
  4. 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 悄悄绕开。

相关推荐
DS随心转小程序2 小时前
实测评测 AI 导出鸭转化效能,全方位优化 DeepSeek 输出 word 文档落地使用效率
人工智能·aigc·word·豆包·deepseek·ai导出鸭
ONEYAC唯样2 小时前
唯样-AI时代,MLCC迎来第二个黄金十年
人工智能
szxinmai主板定制专家2 小时前
基于Zynq MPSoC的多路GMSL相机同步采集与低延迟图像预处理系统|车载视觉高速解决方案
人工智能·zynq·gmsl·rk3588+fpga·rk3576+fpga
王莎莎2 小时前
科研 Agent 先别急着搜:为什么 schema discovery 才是工作流的第一步
人工智能
zhulin10282 小时前
用 TRAE Work 3 小时的工作量,压缩到 20 分钟
人工智能
不加辣椒2 小时前
第10章:上下文工程系统架构设计
人工智能
Bigger2 小时前
把中式美学塞进工具站,到底怎样才不土?我拿烟火食间试了一遍
人工智能·设计·视觉设计
科技新芯2 小时前
通义千问进入特斯拉中国车机深度测试阶段
人工智能·物联网·生活
必须会一定会2 小时前
大模型手搓文件对比工具(6):差异不用再手选
java·人工智能·ai编程