单智能体-面试结果评估(Spring AI Alibaba)

54 阅读9分钟

单智能体-面试结果评估(Spring AI Alibaba)

名词解释

先介绍一下涉及到的核心概念:

  • ChatClient:提供了与 AI 模型通信 Fluent API。(Fluent API:一种 API 设计风格,通过链式调用让代码更直观,如下)
    • // Fluent API:一种 API 设计风格
      ChatResponse chatResponse = chatClient.prompt()
          .user("Tell me a joke")
          .call()
          .chatResponse();
      
  • StateGraph:状态图,通过定义 Node 和 Edge 来管理多个智能体,每个 Node 都是一个智能体,Edge 用于控制数据流转的路径,数据流转过程中通过 OverAllState 管理全局状态。

接下来,先基于 Spring AI Alibaba 的 Graph 能力实现一个简单的面试结果评估智能体,了解 Graph 的运行流程,本节代码:github.com/1020325258/…

前置依赖

  • JDK:版本 21
  • Maven 版本:3.6.3
  • 涉及到的 Pom 依赖如下:
<dependency>
    <groupId>com.alibaba.cloud.ai</groupId>
    <artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
    <version>${spring-ai-alibaba.version}</version>
</dependency>
<dependency>
    <groupId>com.alibaba.cloud.ai</groupId>
    <artifactId>spring-ai-alibaba-graph-core</artifactId>
    <version>${spring-ai-alibaba.version}</version>
</dependency>

定义智能体

**面试答案评估智能体:**根据问题和用户输入的答案,通过 AI 模型来评估答案质量。

首先创建一个 ChatClient 与 LLM 交互,因此这里称它为“智能体”(具备感知、决策、行动、反思能力),这里通过 @Bean 的方式向 Spring 中注册一个 ChatClient 类型的智能体,如下:

@Bean
public ChatClient evaluationAgent(ChatClient.Builder builder) {
    return builder
            .defaultSystem(ResourceUtil.loadResourceAsString(evaluationPrompt))
            .build();
}

这里通过 defaultSystem 设置智能体的提示词,提示词一般包括 System、User、Assistant,在框架里对应 SystemMessage、UserMessage、AssistantMessage,作用分别如下:

  • SystemMessage:定义模型的行为和角色。它为模型提供上下文和行为指导,并确保模型在对话过程中保持一致的行为。
  • UserMessage:通常是用户输入的问题。我们也可以基于用户的提问进一步丰富之后,放入到 UserMessage,使模型可以更好地回答。
  • AssistantMessage:模型基于 SystemMessage、UserMessage 生成的响应,通常和 UserMessage 一起传递给模型,用于提高模型输出内容的质量。

如何让模型输出结构化数据

如果直接让 AI 模型对内容进行评估,生成的是文本内容(非结构化数据),难以从非结构化数据中获取到评估结果,因此需要对模型输出内容的格式进行限制,这些限制可以在提示词中进行声明,如下:

# 面试考核专家
你是一名面试考核专家,负责对候选人针对面试题所给出的答案进行客观、公正、系统的质量评估
## 职责
你的主要职责是:
1. 客观评估:对候选人的面试题答案进行全面、公正的质量评估,不受主观偏见影响。
2. 多维度判断:从完整性、准确性、清晰性、实用性等多个维度进行评估。
3. 明确的判断:给出明确的“通过”或者“不通过”的判断。
4. 建设性反馈:若答案存在不足,需提供具体、可执行的改进建议,指出应如何完善。

## 评估流程
1. 仔细阅读:认真阅读题目和候选人的答案,理解题目考察意图。
2. 逐项检查:从以下维度逐一评估:
   - 完整性:是否全面覆盖了题目要求的所有要点。
   - 准确性:技术描述或逻辑推理是否正确、无明显错误。
   - 清晰性:表述是否清晰、有条理,是否容易理解。
   - 实用性:答案是否具有可操作性、落地性,是否体现出岗位所需能力。
   - 专业性:是否使用了恰当的技术术语和合理的分析方法。
3. 综合判断:基于各项检查结果做出综合判断。
4. 明确回复:直接回答“通过”或者“不通过”。
5. 说明理由:说明判断的理由,特别是判断不通过的具体原因。

## 回复格式

直接输出原始JSON格式的`EvaluationResult`,不要用"```json"包装。`EvaluationResult`接口定义如下:

```ts
interface EvaluationResult{
  passed: boolean;    // true表示通过,false表示不通过
  feedback: string;   // 评估反馈,包括判断理由和改进建议
}
```

示例输出:
```json
{
  "passed": false,
  "feedback": "研究内容不够完整,缺少对技术实现细节的分析,建议补充具体的实现方案和技术选型分析。"
}
```

由于模型输出的内容不是稳定的,可能会出现我们不想要的输出格式,针对这个问题可以有两种方式:

  1. 换更聪明的大模型(参数更多)。
  2. 通过工程的方法解决:先对模型输出的内容进行反序列化转为对象,如果反序列化失败,说明格式不是我们想要的,此时可以通过循环的方式,重新进行生成,直到次数达到上线或得到期望的结果。

定义图(StateGraph)

接下来需要定义 StateGraph,如下,同样通过 @Bean 将 StateGraph 注册到 Spring 中去:

  • KeyStrategyFactory:统一管理 OverAllState 中的状态。
  • StateGraph:图的示例,包括了 Node 和 Edge。这里的 Node 包含了评估节点 EvaluationNode,之后会给出定义。
  • GraphRepresentation:通过代码对 Graph 图进行可视化。
@Bean
public StateGraph evaluationGraph() throws GraphStateException {

    KeyStrategyFactory keyStrategyFactory = () -> {
        HashMap<String, KeyStrategy> keyStrategyHashMap = new HashMap<>();
        // 用户输入
        keyStrategyHashMap.put("query", new ReplaceStrategy());
        keyStrategyHashMap.put("answer", new ReplaceStrategy());
        keyStrategyHashMap.put("evaluation_output", new ReplaceStrategy());
        return keyStrategyHashMap;
    };

    StateGraph stateGraph = new StateGraph("Evaluation Graph", keyStrategyFactory)
            .addNode("evaluation", AsyncNodeAction.node_async(new EvaluationNode(evaluationAgent)))
            .addEdge(StateGraph.START, "evaluation")
            .addEdge("evaluation", StateGraph.END);

    GraphRepresentation graphRepresentation = stateGraph.getGraph(GraphRepresentation.Type.PLANTUML);
    logger.info("\n\n");
    logger.info(graphRepresentation.content());
    logger.info("\n\n");

    return stateGraph;
}

通过 GraphRepresentation 打印出来的图,可视化之后如下(可视化网站:www.plantuml.com/plantuml/um…

在这里插入图片描述

START 和 STOP 是 StateGraph 中默认的开始和终止节点,所有的图必须要有这两个节点。

评估节点(EvaluationNode)

接下来看一下 EvaluationNode 的实现,定义 Node 时需要实现 NodeAction 接口中的 apply() 方法,节点的处理逻辑在该方法中实现。

在 EvaluationNode 节点中,会先通过全局状态 OverAllState 获取用户的输入 query、answer,将 query 和 answer 拼接好之后作为用户提示词,放入到 UserMessage。

通过 ChatClient 将 UserMessage 发送给 AI 模型进行处理,拿到 AI 模型的处理结果后放入到 Map 中返回出去,其他的节点就可以在 OverAllState 中通过该 key 获取到执行结果了,代码如下:

public class EvaluationNode implements NodeAction {

    public ChatClient evaluationAgent;

    public EvaluationNode(ChatClient evaluationAgent) {
        this.evaluationAgent = evaluationAgent;
    }

    @Override
    public Map<String, Object> apply(OverAllState state) throws Exception {
        Map<String, Object> result = new HashMap<>();
        // 获取用户输入
        String query = state.value("query", "");
        String answer = state.value("answer", "");
        // 拼接 UserMessage
        UserMessage userMessage = new UserMessage(
                        "用户输入的问题为:" + query + "\n"
                + "生成的内容为:" + answer
        );
        // 获取 AI 模型执行结果
        String content = evaluationAgent.prompt().messages(userMessage).call().content();
        // 存储执行结果
        result.put("evaluation_content", content);
        return result;
    }
}

图的执行

在执行图之前,需要将 StateGraph 编译为 CompiledGraph,编译时可以设置 Graph 的一些运行时配置,例如检查点(CheckPoint)的保存类型为内存、数据库等等。

构造方法可以通过 Qualifier 指定注入 evaluationGraph,之后编译得到 CompiledGraph,如下:

@RestController
public class EvaluationController {

    private Logger logger = LoggerFactory.getLogger(EvaluationController.class);

    private final CompiledGraph compiledGraph;

    public EvaluationController(@Qualifier("evaluationGraph")StateGraph evaluationGraph) throws GraphStateException {
        // 编译图
        this.compiledGraph = evaluationGraph.compile();
    }
    // ...
}

得到编译后的图之后,通过 CompiledGraph 提供的执行方法就可以得到结果了,模型的处理过程都相对较慢,尤其是在 Graph 定义了很多 Agent 节点,因此通过 CompiledGraph#fluxStream() 获取节点输出(NodeOutput)的 Flux 流,处理之后放入到 Sink 中实时推送给前端,如下:

@GetMapping("/evaluation")
public Flux<ServerSentEvent<String>> evaluate(@RequestParam("query") String query, @RequestParam("answer") String answer) {

    Sinks.Many<ServerSentEvent<String>> sink = Sinks.many().unicast().onBackpressureBuffer();

    Map<String, Object> inputs = new HashMap<>();
    inputs.put("query", query);
    inputs.put("answer", answer);

    // 执行图,获取 Flux 流
    Flux<NodeOutput> nodeOutputFlux = compiledGraph.fluxStream(inputs);

    // 处理节点输出的 Flux 流,给前端响应结果
    GraphProcessor graphProcessor = new GraphProcessor(nodeOutputFlux, sink);
    graphProcessor.process();

    return sink.asFlux()
            .doOnCancel(() -> logger.info("Client disconnected from stream"))
            .onErrorResume(throwable -> {
                logger.error("Error occurred during streaming", throwable);
                return Mono.just(ServerSentEvent.<String>builder()
                        .event("error")
                        .data("Error occurred during streaming: " + throwable.getMessage())
                        .build());
            });
}

这里 GraphProcessor 内部主要是处理每个节点输出(NodeOutput),因为不同节点输出的结果在 OverAllState 中的存储的 key 不同,EvaluationNode 输出结果的 key 为 evaluation_content,取出对应的内容推送给前端:

public class GraphProcessor {

    private final Flux<NodeOutput> nodeOutputFlux;

    // ...
    
    public void process() {
        CompletableFuture.runAsync(() -> {
            // 处理 Flux 流,从 State 获取各个节点的输出
            nodeOutputFlux.doOnNext(output -> {
                String nodeName = output.node();
                String content = "";

                // 如果是开始节点,
                if (StateGraph.START.equals(nodeName)) {
                    content = "START";
                } else if (StateGraph.END.equals(nodeName)) {
                    content = "END";
                } else if (nodeName.equals("evaluation")) {
                    // 如果是评估节点,则输出内容从 OverAllState 中获取 key 为 evaluation_content 的结果
                    content = output.state().value("evaluation_content", "");
                }
                logger.info("node name:" + nodeName + " content:" + content);
                sink.tryEmitNext(ServerSentEvent.builder(nodeName + "处理结果:" + content).build());
            })
            // ...
        });
    }

}

前端展示

通过 ApiFox 演示调用结果,接口的流式输出如下图,输出的内容就是 GraphProcessor 从 NodeOutput 中取出的数据:

在这里插入图片描述

给出 2 个测试用例,模型分别会输出通过和不通过,如下:

# 模型评估不通过
curl --location --request GET 'http://localhost:8080/evaluation?query=sql的更新流程是什么?&answer=sql的更新流程是先在内存中更新,之后异步落入磁盘。'

# 模型评估通过
curl --location --request GET 'http://localhost:8080/evaluation' \
--get \
--data-urlencode "query=sql的更新流程是什么?" \
--data-urlencode "answer=SQL更新流程如下:
1. 连接与认证:连接器接收客户端连接,并进行认证和授权。
2. SQL解析与优化:对SQL语句进行词法分析和语法分析,优化器决定使用哪个索引,并生成执行计划。
3. 执行更新操作:
   - 判断要更新的数据所在的数据页是否在内存(BufferPool)中;若不在,则从磁盘加载到BufferPool。
   - 执行器在BufferPool中更新该行数据。
4. 日志写入(两阶段提交):
   - InnoDB存储引擎将更新操作记录到 redo log,并将其状态设为 prepare。
   - 执行器生成 binlog 并将其写入磁盘。
   - 事务提交,InnoDB将 redo log 的状态改为 commit,表示事务已成功提交。
5. 持久化:
   - 后台IO线程不定期将BufferPool中的脏页(已修改但未写入磁盘的数据)刷入磁盘,完成最终持久化。

该流程确保了事务的原子性、一致性、隔离性和持久性(ACID),
并通过 redo log 和 binlog 的两阶段提交机制保证了主从复制和崩溃恢复的一致性。"

总结

本节实现了一个面试答案评估的智能体,流程并不复杂,主要涉及到模型提示词的设定、StateGraph 的定义以及如何让模型输出指定格式的内容。

基于 SpringAIAlibaba 提供的 Graph 能力,实现多智能体只需要进行组装即可,步骤如下:

  1. 定义 StateGraph,编排 Node 和 Edge。
  2. 定义 Node 节点,Node 节点内部通过 ChatClient 与 AI 模型进行交互。
  3. 编译图得到 CompiledGraph,执行后获取到节点输出的 Flux 流,处理后通过 Sink 实时推送给前端。