☰
AI Agent实战:从零搭建职业规划助手——SpringAI+大模型项目复盘(TaoToken统一Key接入版)
2026/10/3 19:22:06 网站建设 项目流程

1. 从零搭一个职业规划 Agent,为什么我选 SpringAI + 统一 Key 通道

先说清楚这个项目到底在做什么:它是一个能陪你聊职业规划的 AI Agent,你输入「我做了三年 Java 后端,想转 AI 应用方向,该怎么规划」,它会像一位资深职业规划师那样,结合你的简历背景给出分阶段建议,并且回答是流式吐出来的,不是等十几秒一次性蹦出来。适合谁?适合想入门 AI 应用工程化的 Java 开发者,也适合手里有一堆简历、知识库文档,想做成可检索问答的团队。

我这次的技术选型是 Spring Boot 3.4 + SpringAI + 大模型,前端 Vue3。核心链路有三块:SSE 流式对话、RAG 简历/知识库检索、多轮规划记忆。听起来不复杂,但真跑起来,坑集中在三个地方——模型调用的 Key 管理、流式持久化的时机、以及有状态 Agent 的并发安全。

模型调用这块,我一开始是每个环境配一套 Key,本地、测试、演示各一份,结果换模型、换额度的时候到处改配置,非常痛苦。后来统一走 TaoToken 的 API 通道,一个 Key 打通对话模型和嵌入模型,application.yml里只维护一份base-url和api-key,切换模型只改model字段。官网入口在 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 地址是 https://taotoken.net/api ,注意这个 API 地址不带任何查询参数,配置里直接写死就行。

这篇文章不是概念科普,我会把可复制的application.yml、Agent 配置片段、SSE 接口、RAG 检索链路、以及我踩过的真实报错全部摊开。你跟着做,能跑通一个可对话、可检索、可多轮记忆的职业规划助手。下面从环境准备开始。

2. 前置准备:TaoToken 统一 Key 与 SpringAI 依赖对齐

在写代码之前,先把两件事定下来:Key 怎么拿、依赖版本怎么对齐。这两件事没做好,后面全是玄学报错。

2.1 获取统一 Key 与模型 ID

进入控制台创建 API Key,路径是 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite 。创建完你会拿到一串sk-开头的 Key。然后在模型列表里确认你要用的对话模型 ID 和嵌入模型 ID,比如对话用qwen-plus这类,嵌入用对应的 embedding 模型。模型对话页面可以先手动试一句,确认 Key 有效: https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite 。

这里有个细节:SpringAI 的 OpenAI 兼容 starter 需要三个东西——Base URL、API Key、Model ID。Base URL 填https://taotoken.net/api,注意结尾不要多加/v1,具体以接入文档为准,文档地址 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。如果你用的是 Spring AI Alibaba DashScope starter,配置项名字会不一样,但本质还是这三个值。

2.2 依赖版本对齐(这是最大的坑)

我踩过的第一个大坑就是版本矩阵。pom.xml里如果同时出现 SpringAI 的 M6 和 M7 里程碑版本,再叠加 Alibaba Starter,很容易出现类路径冲突、Bean 定义冲突。表现是启动时报NoSuchMethodError或者BeanDefinitionOverrideException。

我的做法是:所有 SpringAI 相关依赖统一到同一条版本线,能用 BOM 就用 BOM 管理。核心依赖大致是这些:

<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-openai-spring-boot-starter</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-markdown-document-reader</artifactId> </dependency>

如果你要接 DashScope,就换成对应的 Alibaba starter,但记住别混用两套对话模型 starter,否则ChatModel会有多个 Bean,注入时直接报冲突。解决方式是加@Qualifier或者在配置里用@ConditionalOnProperty控制哪套生效。

2.3 向量库选型

RAG 需要向量库。开发阶段我用内存版SimpleVectorStore,零依赖,重启数据就没了,适合调试。要持久化就上 PgVector,加 JDBC 和 PostgreSQL 驱动,然后在配置里用开关切换。我建议先用内存版把链路跑通,再换 PgVector,不然一开始就卡在数据库连接上,容易劝退。

3. 可复制配置:application.yml 与 Agent 配置片段

这一节是全文最核心的部分,配置直接抄,改 Key 和模型 ID 就能用。

3.1 application.yml 完整片段

server: port: 8080 spring: ai: openai: base-url: https://taotoken.net/api api-key: ${TAOTOKEN_API_KEY} chat: options: model: qwen-plus temperature: 0.7 embedding: options: model: text-embedding-v3 app: rag: use-pgvector: false top-k: 4 similarity-threshold: 0.5 knowledge-path: classpath:knowledge/ memory: max-history-lines: 20 storage-dir: ./chat-memory security: enabled: false

几个关键点解释一下。base-url就是 TaoToken 的 API 地址,api-key用环境变量注入,别硬编码进仓库。chat.options.model和embedding.options.model分别对应对话和嵌入模型,这两个值从控制台模型列表里拿。app.rag.use-pgvector是开关,false 走内存库,true 走 PgVector。app.memory.max-history-lines控制拼进 Prompt 的历史行数,太大撑爆上下文,太小记不住。

3.2 Agent 与 ChatClient 配置类

@Configuration public class AgentConfig { @Bean public ChatClient careerChatClient(ChatModel chatModel, VectorStore vectorStore, @Value("${app.rag.top-k:4}") int topK) { QuestionAnswerAdvisor qaAdvisor = QuestionAnswerAdvisor.builder(vectorStore) .searchRequest(SearchRequest.builder().topK(topK).build()) .build(); return ChatClient.builder(chatModel) .defaultSystem("你是一位资深职业规划师,回答要具体可落地,拒绝空话套话。") .defaultAdvisors(qaAdvisor) .build(); } }

这段配置做了三件事:绑定系统提示词、挂上 RAG 检索 Advisor、指定 topK。QuestionAnswerAdvisor会在每次对话前自动去向量库检索相关文档,把结果注入到 Prompt 里。这样你问「我的简历适合投哪些岗位」,它会先检索简历片段再回答。

3.3 多轮记忆的持久化配置

记忆我用的是纯文本日志方案,以chatId为键,把每轮 USER 和 ASSISTANT 追加写入chat-memory/<chatId>.log。新请求时读最近 N 行拼进 Prompt。为什么不直接用官方 ChatMemory Advisor?因为流式场景下,官方 Advisor 的写入时机不好控制,客户端提前断开时容易丢内容。自己写日志反而更可控。

public String buildMessageWithHistory(String chatId, String question) { List<String> lines = readLastLines(chatId, maxHistoryLines); StringBuilder sb = new StringBuilder(); for (String line : lines) { sb.append(line).append("\n"); } sb.append("用户最新问题:").append(question); return sb.toString(); }

注意这里是把历史拼成一段 user 文本,而不是维护完整的 message list。这样做的好处是实现简单,坏处是模型对角色区分没那么清晰。如果你要更规范,可以改成维护List<Message>,但流式持久化要同步改。

4. 验证请求:SSE 流式对话与 RAG 检索跑通

配置写完,接下来验证链路。分三步:先验证模型能通,再验证 SSE 流式,最后验证 RAG 检索。

4.1 先验证模型调用

写一个最简单的测试接口,同步调用一次,确认 Key 和模型 ID 没问题:

@GetMapping("/ai/ping") public String ping() { return chatClient.prompt() .user("用一句话介绍你自己") .call() .content(); }

启动后访问http://localhost:8080/ai/ping,如果返回一句中文介绍,说明 Base URL、Key、Model ID 三件套都对了。如果报 401,往下看第 5 节的排错。

4.2 SSE 流式接口

职业规划师的流式接口我用Flux<ServerSentEvent<String>>,GET 方式,参数走查询字符串:

@GetMapping(value = "/ai/career_app/chat/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<ServerSentEvent<String>> chatSse(@RequestParam String message, @RequestParam String chatId) { String prompt = memoryService.buildMessageWithHistory(chatId, message); return careerChatClient.prompt() .user(prompt) .stream() .content() .map(chunk -> ServerSentEvent.builder(chunk).build()) .doFinally(signal -> memoryService.append(chatId, message, collected)); }

这里有个关键点:持久化必须放在doFinally里,不能只放doOnComplete。因为用户中途关页面、取消订阅时,doOnComplete不触发,这一轮 assistant 的内容就丢了,下一轮就「记不住」。doFinally覆盖完成、取消、出错三种情况,这是我在项目里明确修过的一个 bug。

前端用EventSource消费,注意EventSource只支持 GET,不能自定义请求头,所以参数只能走 URL。如果你需要 POST + 自定义头,得改用fetch+ReadableStream。

4.3 RAG 检索验证

知识库文档放classpath:knowledge/下,用 Markdown 格式。启动时异步加载,避免阻塞启动:

@Bean public ApplicationRunner loadKnowledge(VectorStore vectorStore, EmbeddingModel embeddingModel) { return args -> CompletableFuture.runAsync(() -> { try { List<Document> docs = new MarkdownDocumentReader("classpath:knowledge/").get(); vectorStore.add(docs); } catch (Exception e) { log.warn("知识库加载失败,以空库启动", e); } }); }

验证方式:问一个只有知识库里才有的问题,比如「我简历里写的那个项目用了什么技术栈」,如果回答能引用到简历内容,说明检索生效。如果回答是泛泛而谈,说明没检索到,检查 topK 和相似度阈值。

4.4 查询重写提升召回

光靠 embedding + topK 往往不够,用户问「那个项目怎么样」这种省略主语的句子,检索会失败。我加了一层查询重写,用同一个模型把问题改写成更利于检索的表述:

public String rewrite(String rawQuery) { return chatClient.prompt() .user("请把下面的问题改写成适合向量检索的完整问句,只输出改写结果:\n" + rawQuery) .call() .content(); }

改写后再交给向量库检索,召回率明显提升。代价是多一次模型调用,简单轮次可以跳过。

5. 常见报错排查:401、local proxy failed、reading choices、OAuth

这一节按真实报错来,都是我实际遇到过的。

401 Unauthorized:最常见。先确认api-key环境变量有没有注入成功,echo $TAOTOKEN_API_KEY看一下。再确认base-url是不是https://taotoken.net/api,结尾别多加/v1或斜杠。如果 Key 是从控制台复制的,注意别带空格。还有一种情况是 Key 额度用完了,去控制台看一下余额。

local proxy failed / connection refused:这个报错通常是本地网络或代理配置问题。检查你的application.yml里有没有残留的代理配置,或者系统环境变量HTTP_PROXY有没有指向一个不可用的地址。把代理相关配置清掉,直连 API 地址即可。注意不要配置任何非官方的转发地址。

reading choices 报错 / 返回体解析失败:这个一般是模型返回格式和客户端预期不一致。检查你用的 starter 是不是 OpenAI 兼容格式,模型 ID 是不是对话模型(别把 embedding 模型填到 chat 里)。如果返回体里没有choices字段,说明请求根本没到对话接口,大概率是 base-url 拼错了。

OAuth / 认证方式不匹配:有些 starter 默认走 OAuth 流程,但 TaoToken 用的是 API Key 认证。确认你用的是api-key配置项,而不是client-id/client-secret那套。如果 starter 强制走 OAuth,换一个支持 API Key 的 starter。

Bean 注入冲突:报BeanDefinitionOverrideException或NoUniqueBeanDefinitionException。原因是多个ChatModel或VectorStoreBean 同时注册。解决方式是加@Qualifier指定,或者用@ConditionalOnProperty控制哪套生效。我项目里内存库和 PgVector 同时存在时,就是靠resolveRagVectorStore方法手动选。

SSE 客户端断开刷错误日志:报ClientAbortException或AsyncRequestNotUsableException。这是客户端断开后服务端还在写响应导致的,属于正常现象,在全局异常处理器里单独捕获并降级为 debug 日志即可,别让它刷满 error 日志。

前端 AI 气泡空白,结束才一次性显示:这是 Vue 响应式问题。对数组里的普通对象做增量字段更新,Vue 追踪不到。解决方式是用reactive包裹消息对象,或者每次替换整个对象。我在CareerChatView.vue里就是用reactive修的。

6. 继续深入:Coding Plan 与接入文档

链路跑通之后,如果你想把这类 Agent 用到长期编码或更复杂的多步任务上,可以了解一下 Coding Plan,地址 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 。它更适合需要持续调用、多轮 Agent 循环的场景。

接入过程中如果遇到配置问题,优先查接入文档 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite ,里面把 Base URL、Key、Model ID 三件套和常见错误码都列清楚了。需要新建或管理 Key 就去 API Keys 页面 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 。

最后留一个我踩过的坑给你:有状态 Agent 千万别用 Spring 单例。我一开始把 Manus 智能体做成单例 Bean,messageList和state都是实例字段,结果两个用户同时请求会串话,A 的上下文跑到 B 的回答里。后来改成每请求创建一个 Agent 实例,或者把状态抽到会话级对象里,问题才解决。如果你也在做多步工具调用的 Agent,这一点务必注意。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询