相关技术栈
- 后端:JDK17、SpringAI
- 数据库:PostgreSQL18
- Embedding:Ollama,bge-m3
- 前端:NodeJS22,vibe coding出的前端页面
数据模型
项目涉及到七张表,6张传统的SQL表与一个chunk_bge_m3向量数据表。
- agent表,主要存每个创建的agent的信息,包括agentName、系统提示词、关联的模型、允许使用的工具、关联的知识库、温度、top_p等配置。
- chat_session表,会话记录表,session_id、存会话title、绑定的agent
- chat_message表,session_id、role和content、额外的metadata如模型参数、RAG等
- knowleage_base表,
- document表,
- chunk_bge_m3表,存储向量化的RAG数据,基于标题-内容拆分文档内容,embedding字段存标题向量、content字段存内容。
SpringAI框架
自己总结的几个特点:
- SpringAI天然支持SpringBoot,作为框架,开箱即用,能帮人快速搭建一个agent。
- AI Agent后端设计的最重要最复杂的部分:模型维护、上下文管理(消息组织与上下文压缩),不同模型的底层通信协议实现,框架都帮我们做好了(通过ChatCilent对象和ChatMemory对象)。
- SpringAI也提供自动工具执行功能和自动工具调用决策(本项目是自己管理工具决策),同时,通过
@Tool注解,就能把Java方法注册为工具,供toolCallingManager自动选择调用
SpringAI框架结合不同模型厂商的starter依赖,提供了对不同模型的封装,如下:
<dependencies> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-deepseek</ artifactId></dependency> <dependency> </dependencies>
在application.yaml中配置好参数后,就可以被SpringAI使用。
一个模型,即为一个chatclinet对象,它屏蔽了底层调用细节,只暴露统一的对话接口。
@configuration
public class MultiChatclientconfig {
@Bean( "deepseek-chat")
public chatclient deepseekchatclient(DeepSeekchatModel model) {
return Chatclient.create ( model);
}
@Bean( "glm-4.6")
public chatclient zhiPuchatclient(zhiPuAiChatModel model) {
return chatclient.create( model);
}
}
Spring 会自动把所有Chatclient类型的Bean注入到一个Map中,Key为 Bean名称,Value为chatclient实例本身。 实现了模型注册。
@component
public class ChatclientRegistry {
private final Map<string,chatclient> registry;
public ChatclientRegistry(Map<string,chatclient> registry) {
this.registry = registry;
}
public chatclient get( string modelName) {
return registry.get( modelName ) ;
}
}
Think-Execute循环流程
创建agent实例、调用.chat、执行think-execute流程,for循环中发送prompt让LLM决策是否需要调用工具,若需要则处理工具调用;若不需要则结束think-execute、处理工具调用后的结果,加入chatMemory中,作为会话记忆的一部分。
JChatMindV2 agent = new JChatMindV2(
"test-agent-v2", "测试 Agent V2",
"你是一个智能助手...", chatClient, 20, "test-session-v2",
Arrays.asList(toolCallbacks)
);
// 2. 执行循环
for (int i = 0; i < MAX_STEPS && agentState != AgentState.FINISHED; i++) {
step();
if (i >= MAX_STEPS - 1) {
agentState = AgentState.FINISHED;
log.warn("达到最大步骤数");
}
}
// 3. 步骤:think 或 finish
protected void step() {
if (think()) {
execute(); // 有工具则执行
} else {
agentState = AgentState.FINISHED; // 无工具则结束
}
}
// 4. 思考:LLM 决策
protected boolean think() {
this.lastChatResponse = chatClient
.prompt(Prompt.builder()
.chatOptions(this.chatOptions)
.messages(chatMemory.get(sessionId))
.build())
.system("你是决策模块,根据上下文决定是否调用工具。")
.toolCallbacks(availableTools != null ? availableTools.toArray(new ToolCallback[0]) : new ToolCallback[0])
.call()
.chatClientResponse()
.chatResponse();
List<AssistantMessage.ToolCall> toolCalls = this.lastChatResponse
.getResult()
.getOutput()
.getToolCalls();
// 无工具调用时,将回复加入记忆
if (toolCalls.isEmpty()) {
chatMemory.add(sessionId, this.lastChatResponse.getResult().getOutput());
}
return !toolCalls.isEmpty(); // 返回是否需要执行工具
}
最后的最后,工具的具体执行,也是交由SpringAI框架做的。
private void execute() {
// 手动调用 ToolCallingManager 执行工具
ToolExecutionResult toolExecutionResult = toolCallingManager.executeToolCalls(
prompt,
this.lastChatResponse
);
// Spring AI 内部做的事情:
// 1. 解析 lastChatResponse 中的 ToolCall(工具名、参数)
// 2. 找到对应的 Java 方法(通过 @Tool 注解)
// 3. 反射调用方法,传入参数
// 4. 获取返回值
// 5. 封装成 ToolResponseMessage
}
知识库与RAG
RAG能力在本项目中,是以工具的形式提供的。
流程:用户提出问题 —> Agent判断:我是否需要查阅知识库 —> Agent调用KnowledgeTool —> 系统执行向量检索 —> 返回相关文档内容 —> Agent基于内容生成最终回答。
目前只支持markdown文本。
PART1:内容放进知识库
内容向量化:文档分割成为一个一个chunk、送入向量模型embedding向量化,结果存入postgreSQL内。
怎么拆文档:分割chunk的方式有很多种,例如固定长度分割、按照段落分割、本项目基于markdown章节标题拆。提取出章节标题,把标题向量化,chunk_bge_m3表中的embedding字段存标题向量、content字段存内容。
解析markdown文档,基于AST方式解析:
// 基于flexmark解析器
@Override
public List<MarkdownSection> parseMarkdown(InputStream inputStream) {
try {
// 读取文件内容
originalMarkdownContent = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
// 解析 Markdown
Document document = parser.parse(originalMarkdownContent);
// 提取标题和内容
List<MarkdownSection> sections = new ArrayList<>();
extractSections(document, sections);
log.info("解析 Markdown 完成,共提取 {} 个章节", sections.size());
return sections;
} catch (Exception e) {
log.error("解析 Markdown 失败", e);
throw new RuntimeException("解析 Markdown 失败: " + e.getMessage(), e);
}
}
向量化title,并存入数据库。
for (MarkdownParserService.MarkdownSection section : sections) {
String title = section.getTitle();
String content = section.getContent();
if (title == null || title.trim().isEmpty()) {
continue;
}
// 对标题进行 embedding
float[] embedding = ragService.embed(title);
// 创建 ChunkBgeM3 实体
ChunkBgeM3 chunk = ChunkBgeM3.builder()
.kbId(kbId)
.docId(documentId)
.content(content != null ? content : "")
.metadata(null) // 可以存储标题信息到 metadata
.embedding(embedding)
.createdAt(now)
.updatedAt(now)
.build();
// 插入数据库
int result = chunkBgeM3Mapper.insert(chunk);
文本向量化流程:
private float[] doEmbed(String text) {
EmbeddingResponse resp = webClient.post()
.uri("/api/embeddings")
.bodyValue(Map.of(
"model", "bge-m3",
"prompt", text
))
.retrieve()
.bodyToMono(EmbeddingResponse.class)
.block();
Assert.notNull(resp, "Embedding response cannot be null");
return resp.getEmbedding();
}
- 调用本地 Ollama 服务:地址: http://localhost:11434、端点: /api/embeddings
- 发送请求:{
“model”: “bge-m3”, // 使用 BGE-M3 嵌入模型
“prompt”: “文本内容” // 待向量化的文本
} - 接收响应
{"embedding": [0.123, -0.456, 0.789, ...] // 1024 维向量},返回 float[] 向量数组
最终的向量并没有被存入某个独立的向量数据库,而是直接使用PostgreSQL + pgvector:embedding VECTOR(1024) NOT NULL
这样做的原因并不复杂:
- 不需要引入额外的基础设施。
- 向量数据和业务数据在同一数据库中·查询路径清晰、可调试、可观测。
- 通过pgvector提供的<->操作符,我们可以非常直接地完成Top-K相似度搜索。
PART2:知识库检索内容
- 用户提问(往往缺少背景信息)。对用户问题进行Embedding。
- 使用用户问题的向量在向量数据库中执行相似度检索。
- 检索到的 Top-K条chunk,会被作为补充上下文,与用户原始问题一起发送给大模型·模型基于用户问题和检索到的知识片段生成最终回答并返回给用户。
—次完整的相似度搜索流程如下:
- 将查询文本生成Embedding
- 使用该向量在指定知识库范围内执行相似度排序
- 返回最相关的若干个章节内容
- 对应的SQL查询也非常直观:
<select id="similaritysearch" resultMap="BaseResultMap">
<! [CDATA[
SELECT id,
kb_id,doc_id,content,metadata,embedding,created_at,updated_at
FROM chunk_bge_m3
WHERE kb_id = CAST(#{ kbId} AS uuid)
ORDER BY embedding <->#{ vectorLiteral} : : vectorLIMIT #{limit}
]]>
</ select>
RAG在本项目中,作为一个可调用的工具,提供给LLM(通过提示词,告诉LLM可用哪些知识库,知识库的大致用途),让LLM来判断是否要用知识库。
最后:整体的流程架构
整体的前后端流程,大概可描述如下:
- 前端用户发送请求,后端先持久化请求到数据库,发布事件,并返回给前端。
- 后端有一个
ChatEven事件监听器,事件监听器来真正处理Agent调用流程。事件监听器是一个异步任务,这意味着无论Agent跑多久,无论中途调用多少功能,都不会影响用户继续操作页面,监听器拿到事件之后,只做一件事:创建个Agent实例,并调用run()。 - Agent的流程,已经在
Think-Execute循环流程中介绍过。 - 消息不是立即推送的,相较于completeAPI协议的LLM每delta都推送,本项目在运行过程中,并不会每生成一条消息就立刻推送,而是先统一保存到pendingChatMessages,每次生成一条完整消息的时候,再一次性推送。
问题
- tinyChatMind项目中,设计了AI的几个状态,用途是为什么,对比实习的项目,实习的项目为什么没有状态?
IDLE, // 空闲 PLANNING, // 计划中 THINKING, // 思考中 EXECUTING, // 执行中 FINISHED, // 正常结束 ERROR // 错误结束
- tinyChatMind项目,对比实习的项目,缺陷的地方:
- 工具调用,实习项目是基于标签解析的,tinyChatMind交由SpringAI
- 上下文管理,实习项目自己组装历史消息,进行上下文压缩,设计底层通信协议,
- 模型管理,实习项目基于微服务配置+模型池管理,tinyChatMind通过注册表模式管理。
- 待补充…