Spring AI递归顾问:两模型互相折磨 实现自评审质量门禁

让AI自己当裁判:Spring AI里的递归评审官

AI回答靠不靠谱?让另一个AI来打分,不行就重写,直到满意为止。Spring AI用递归顾问把这套流程打包成一个透明组件,调用方根本不知道背后发生了多少次自我较劲。

评审官模式像极了代码审查

想象一下你写完一段代码,交给组里那个最较真的高级工程师过目。他不会只说“不错”或者“不行”,而是给你一条条列出来:哪里逻辑有漏洞,哪里命名不清晰,哪里性能可能出问题。然后你改完,他再看,再给意见,直到他觉得可以了为止。这套流程在软件开发里叫代码审查,目的是用多一双眼睛来拦截低级错误。

现在把写代码的人换成AI,把审查代码的人换成另一个AI,就得到了LLM-as-a-Judge这个模式。第一个AI负责回答用户的问题,第二个AI负责给这个回答打分,并且写评语。如果分数没达标,第一个AI就根据评语修改自己的回答,然后第二个AI再打分。如此循环,直到回答质量过关,或者循环次数用完了为止。

这套机制最妙的地方在于,它不需要你重新训练模型,不需要你花钱雇人盯着,也不需要你改动任何调用代码。你原来怎么调AI,现在还怎么调AI,质量过滤这件事在背后悄悄就做了。对于那些回答不好会直接伤害用户体验的场景,比如客服机器人回答错账单问题,或者医疗助手给错用药建议,这套隐形质检闸门就特别管用。

Spring AI里的递归顾问刚好能干活

Spring AI里有一个概念叫Advisor,中文可以理解为“顾问”或者“拦截器”。它像一个检查站,守在AI聊天客户端的请求和回应必经之路上。在请求发出去之前,它能看一眼甚至改一改;在回应拿回来之后,它也能拦截下来处理一下再交给调用方。但普通的顾问只做一次拦截,调用一次模型就完事了。

递归顾问就不一样了。它内部自己还藏着一个AI聊天客户端,意味着它可以在自己的逻辑里再次发起AI调用。这就给了我们一个发挥空间:第一次调用拿回答,第二次调用拿评分和评语,第三次调用拿改进后的回答。这个“生成-评审-改进”的循环,被递归顾问包装成了一个独立的自包含零件,对外界完全透明。

调用方根本不知道里面转了多少圈,它只知道自己发了一个问题,然后收到了一段回答。而实际上这段回答可能已经被AI自己推翻重写了好几遍,还被另一个AI打分通过了才放行。

第一步:定义打分结果长什么样

评审官给出评价,不能光说“还行”或者“不行”,得有一个结构化的结论。Java里用record来定义这个结论最方便,就两个字段:一个是分数,double类型,0.0到1.0之间;一个是书面反馈,字符串,说明哪里有问题。

java
public record Verdict(double score, String feedback) {}

分数定在0.0到1.0之间,1.0表示完美,0.0表示完全错误或者有害内容。反馈不是写给用户看的,是写给生成回答的那个AI看的,目的是告诉它哪里需要改进。比如“你提到了基本概念,但缺少具体例子”或者“解释太短,没有覆盖边界情况”。

这个反馈的措辞很关键。因为后面我们要把它原样拼回请求里,让生成模型看到。如果反馈写得模糊,比如“回答不好”,生成模型根本不知道从哪改起。如果写得具体,比如“缺少对并发安全性的讨论”,那生成模型就清楚下一步该补充什么。

第二步:给评审官写好操作手册

评审官自己也是一个AI模型,它需要一份清晰的操作说明,也就是系统提示词。这份提示词要像考场上的评分标准一样明确,不能含糊。里面要列出打分档位:什么情况给1.0,什么情况给0.7,什么情况给0.4,什么情况直接0.0。

java
private static final String JUDGE_SYSTEM_PROMPT = """
    You are a strict quality evaluator for AI-generated answers.
    
    Given a user question and an AI-generated answer, rate the answer quality.
    
    Use this rubric:
    - 1.0: Complete, accurate, and clearly explained
    - 0.7: Mostly correct but missing details or clarity
    - 0.4: Partially correct or overly vague
    - 0.0: Incorrect, irrelevant, or harmful
    
    Respond ONLY with a valid JSON object. Do not add any explanation outside the JSON.
    Format: {"score": <0.0 to 1.0>, "feedback": ""}
    """;

1.0对应完整、准确、解释清楚。0.7对应大致正确但缺细节。0.4对应部分正确但太含糊。0.0直接对应错误、不相关或者有害内容。这四档相当于给评审官画了四条线,它只要照着对号入座就行。

最关键的一点是:评审官的输出必须是严格的JSON格式,不能有多余的闲聊或者解释。因为我们要用程序解析这个结果,如果它输出了一段散文,解析器就崩溃了。强制JSON输出可以让后续的解析完全自动化,不需要手工写正则或者try-catch去捞数据。Spring AI可以直接把JSON映射到我们定义的那个record对象上,一行解析代码都不用写。

第三步:递归顾问的内部构造

递归顾问类本身实现了一个叫CallAdvisor的接口,这个接口要求实现adviseCall方法。顾问内部持有三个关键东西:它自己的AI聊天客户端、一个分数阈值、一个最大改进次数。

java
public class LlmJudgeAdvisor implements CallAdvisor {
    private final ChatClient judgeClient;
    private final double scoreThreshold;
    private final int maxRefinements;
    public LlmJudgeAdvisor(
      ChatClient judgeClient,
      double scoreThreshold,
      int maxRefinements
    ) {
        this.judgeClient = judgeClient;
        this.scoreThreshold = scoreThreshold;
        this.maxRefinements = maxRefinements;
    }
    @Override
    public int getOrder() {
        return 0;
    }
    @Override
    public String getName() {
        return "LlmJudgeAdvisor";
    }
    // ...
}

它自己的AI客户端专门用来调用评审官模型。这里有个设计细节是,评审官最好跟生成器用不同的模型,或者至少用不同的配置。如果同一个模型既生成又评审自己生成的回答,它可能会对自己的回答打分偏高,像学生给自己批卷子一样下不去手。用独立的客户端实例可以避免这个问题,哪怕底层的API key和模型名都一样,代码层面也是隔离的。

分数阈值决定了什么水平算过关。如果设成0.75,那评审官打分低于0.75的回答都要重写。最大改进次数设成2或者3,防止回答死活过不了关时无限循环浪费钱。

第四步:循环逻辑的开关

adviseCall方法是整个递归的入口。它里面有一个for循环,从第一次尝试开始,一直跑到最大次数加一为止。每次循环都调用chain.copy(this).nextCall(request),这句看起来有点绕,它的意思是“生成一个包含当前顾问在内的子调用链,然后发起下一次模型调用”。

java
@Override
public ChatClientResponse adviseCall(ChatClientRequest request, CallAdvisorChain chain) {
    for (int attempt = 1; attempt <= maxRefinements + 1; attempt++) { 
        ChatClientResponse response = chain.copy(this).nextCall(request);
        if (attempt > maxRefinements) {
          return response;
        }
        Verdict verdict = evaluate(request, response); 
        if (verdict.score() >= scoreThreshold) {
            return response;
        }
        request = addFeedback(request, verdict.feedback());
    }
    return chain.copy(this).nextCall(request);
}

这里递归的奥妙在于:每一次调用生成模型时,这个顾问都会再次被触发。也就是说,生成模型每回答一次,评审官就会被自动叫出来打分,而不是等循环结束再统一评。这就是递归两个字的来源——顾问内部嵌套调用自己。

如果循环次数用完了,直接返回最后一次的回答,不再评审。因为再评审也没意义了,我们已经没有机会再改进了。如果评审分数达标了,也立即返回,把回答放行给调用方。如果分数不达标,就调用addFeedback方法,把评审官的反馈拼进原始请求里,然后继续下一轮循环。

第五步:把反馈塞回请求里

反馈怎么拼进请求,这里面有讲究。我们不能把原始问题丢掉,也不能覆盖掉用户原本的提问。正确做法是保留原问题,然后在后面追加一段话,格式大概是:“你之前的回答不够好。反馈意见是:某某某。请提供一个改进后的回答。”

java
private ChatClientRequest addFeedback(ChatClientRequest original, String feedback) {
    Prompt augmented = original.prompt()
      .augmentUserMessage(msg -> msg.mutate()
          .text(msg.getText()
              + "\n\nYour previous answer was insufficient. Feedback: " + feedback
              + "\nPlease provide an improved answer.")
          .build());
    return original.mutate().prompt(augmented).build();
}

这样生成模型看到的输入就变成了“原问题 + 历史反馈 + 改进指令”。它既知道用户在问什么,又知道之前哪里没做好,还知道现在需要干什么。这种上下文完整性对改进效果至关重要。如果只把反馈传进去而丢了原问题,模型就不知道自己在回答什么。如果只传原问题而不传反馈,它又会重复犯同样的错误。

Spring AI提供的augmentUserMessage方法专门干这个事,它返回一个请求的副本,只改用户消息那一部分,系统提示词、工具定义、对话历史全部保持原样。这种细粒度的不可变更新保证了我们不会误伤其他配置。

第六步:配置和阈值交给外部

顾问类本身不硬编码阈值,而是通过构造函数从外面注入。这样在Spring配置类里就可以从application.properties文件读取数值,方便不同环境调不同的严格程度。

java
@Configuration
public class ChatConfig {
    @Bean
    public ChatClient chatClient(
      ChatClient.Builder builder, 
      LlmJudgeAdvisor judgeAdvisor
    ) {
        return builder
          .defaultAdvisors(judgeAdvisor)
          .build();
    }
    @Bean
    public LlmJudgeAdvisor llmJudgeAdvisor(
      ChatClient.Builder builder,
      @Value("${judge.score-threshold:0.7}") double scoreThreshold,
      @Value("${judge.max-refinements:2}") int maxRefinements
    ) {
        return new LlmJudgeAdvisor(
          builder.build(),
          scoreThreshold,
          maxRefinements
        );
    }
}

配置类里要注册两个bean。一个是主聊天客户端,它把评审顾问挂到默认顾问列表里,这样所有通过这个客户端发起的调用都会自动过一遍评审流程。另一个是评审顾问本身,它从builder里拿到自己的聊天客户端,再配上阈值和次数限制。

注意评审顾问的ChatClient是从同一个ChatClient.Builder构建的,但build()之后就成了独立实例。主客户端和评审客户端用的是同样的底层模型配置,但它们各自维护自己的状态,互不干扰。

properties
spring.ai.openai.api-key=${OPENAI_API_KEY}
spring.ai.openai.chat.options.model=gpt-4o-mini
judge.score-threshold=0.75
judge.max-refinements=2

在application.properties里可以配置四项:OpenAI的API密钥、模型名称、评分阈值、最大改进次数。评分阈值设成0.75意思就是回答至少要达到良好级别才放行,最大改进次数设成2意味着最多重写两次,第三次不管多少分都直接返回。

第七步:测试时不用真花钱

测试这个功能时,不需要真的调用OpenAI API,不需要花钱,也不需要网络。Spring Boot测试里可以用MockitoBean把ChatModel替换成模拟对象,这样所有AI调用都被拦截,返回我们预设的固定回答。

java
@SpringBootTest
class LlmJudgeAdvisorTest {
    @MockitoBean
    ChatModel chatModel;
    @Autowired
    ChatClient chatClient;
    @Test
    void givenLowQualityAnswer_whenAdvisorRuns_thenAnswerIsRefined() {
        when(chatModel.call(any(Prompt.class)))
          .thenReturn(buildChatResponse("It runs Java."))
          .thenReturn(buildChatResponse("{\"score\": 0.3, \"feedback\": \"Too vague.\"}"))
          .thenReturn(buildChatResponse(
            "The JVM executes Java bytecode, manages memory, and enables platform independence."))
          .thenReturn(buildChatResponse("{\"score\": 0.9, \"feedback\": \"Complete and accurate.\"}"));
        String result = chatClient.prompt()
          .user("Explain what a JVM is.")
          .call()
          .content();
        assertThat(result).contains("bytecode");
    }
    private ChatResponse buildChatResponse(String content) {
        return new ChatResponse(List.of(new Generation(new AssistantMessage(content))));
    }
}

测试方法里按顺序mock四次调用:第一次是生成器给出一个质量不高的回答,第二次是评审官给出低分和反馈,第三次是生成器根据反馈给出改进后的回答,第四次是评审官给出高分通过。这四步正好模拟了一次完整的“生成-评审-改进-再评审”循环。

断言部分只检查最终回答里是否包含某个关键词,比如“bytecode”。因为改进后的回答里应该覆盖到这个核心概念。如果最终回答里没有这个词,说明循环逻辑没正常工作,改进没生效。

这套测试方法的精妙之处在于,被测试的ChatClient和顾问都是真实对象,只有底层的模型被替换了。所以整个递归逻辑、请求修改、循环控制全部在生产级别的代码路径上运行,而不是在简化版的测试替身里。

最终效果是调用方无感知

回顾一下整个流程的最终形态。业务代码里只有一行调用:

chatClient.prompt().user("解释一下JVM是什么").call().content()

这行代码返回的字符串,可能已经是AI自己跟自己较劲了两三轮之后的产物。第一版回答可能只有一句话,被评审官打了低分,然后生成器根据反馈重写,扩展开来解释了字节码执行、内存管理、平台无关性,评审官这才给了高分放行。

调用方完全不知道背后发生过这些事。它拿到的就是一个质量过关的回答。这种把质量保障逻辑塞进基础组件里的做法,让AI应用的上层代码保持干净,不需要每个调用点都去写if-else判断质量。

递归顾问把生成和评审打包成一个透明盒子,既保住了代码的整洁性,又保住了回答的可靠性。



 AI写的东西不靠谱,但你让另一个AI来当监工打分,俩AI互相较劲几轮后反而产出了靠谱结果。
俩模型互相折磨几轮后交出来的东西居然能用了,人不在场,钱没多花,代码没改,质量自己上来了。