Java工程师手撸Agent:Spring Boot+Redis+MySQL实战

发布时间:2026/11/3 8:21:46
Java工程师手撸Agent:Spring Boot+Redis+MySQL实战

1. 为什么《码上面试》Agent项目值得从第一行代码开始啃?

“Agent”这个词最近半年在技术圈的热度,已经不是“火”能形容的了——它像一块烧红的铁板,踩上去就烫脚。但绝大多数人刷到“AI Agent”“Agent框架”“吴恩达Agent教程”这类词时,脑子里浮现的还是模糊的、带点科幻感的概念:智能体、自主决策、多步推理……然后点开文档,三分钟后关掉页面,因为满屏是LangChain、LlamaIndex、Tool Calling、ReAct这些术语堆砌的抽象图示,连个能跑起来的Hello World都找不到。我去年带三个应届生做内部技术分享,让他们用“Agent”写一个能查Java面试题的简单服务,结果两人卡在环境配置,一人卡在“到底该用哪个框架”,最后交上来的是个硬编码的if-else问答列表——这根本不是Agent,这是电子版FAQ。

而《码上面试》这个项目标题里的“码上”,是双关,也是态度:代码要真正在本地跑起来,面试题要真正在数据库里存得住,Redis缓存要真正在命令行里看得见。它不讲大模型怎么微调,不画多智能体协作的UML图,它只解决一个最朴素的问题:一个Java后端工程师,如何用自己熟悉的Spring Boot + MySQL + Redis技术栈,亲手搭出第一个有记忆、有状态、能调用外部数据源的Agent雏形?这就是它和市面上90%“Agent教程”的本质区别——它不假设你已掌握LLM API调用,它假设你刚修完《Java Web开发》,手边开着IntelliJ IDEA,电脑里装着MySQL 8.0和Redis 7.0。

所以这篇“学习记录(一)”,不是照着README复制粘贴的流水账。它是我在把项目从GitHub clone下来、执行mvn clean install失败三次、debug到凌晨两点终于看到控制台输出“Agent initialized with 3 knowledge sources”之后,撕掉所有高大上的概念包装,回归到最原始的工程现场,记下的第一手实操笔记。关键词里没有“大模型”,只有Java、Redis、MySQL——这恰恰说明:Agent的底层骨架,从来就不是AI,而是传统后端工程师每天打交道的状态管理、数据流转、异步编排与错误兜底。你不需要先成为Prompt Engineer,你只需要搞懂:为什么一个简单的“查Java集合类面试题”请求,背后要经过Redis缓存穿透校验、MySQL事务隔离级别选择、以及Agent状态机在两次HTTP请求间的持久化保存?搞懂这个,你才算真正“码上”了Agent的第一步。

2. 项目骨架解剖:Spring Boot如何撑起Agent的“脊椎”

《码上面试》项目没用任何第三方Agent框架(比如LangChain4j或Spring AI),它的核心Agent引擎是纯手写的Java类。这不是炫技,而是刻意为之——当你想理解Agent的“心跳”怎么跳,就必须亲手缝合每根血管。整个项目结构乍看平平无奇:标准的Spring Boot三层分层(controller → service → dao),但关键在于AgentStateService和KnowledgeSourceManager这两个类,它们构成了整个Agent的“脊椎”。

2.1 AgentStateService:状态不是变量,是需要持久化的生命体

在传统Web开发中,“用户状态”常被简化为Session或JWT Token里的几个字段。但Agent的状态完全不同:它必须跨多次HTTP请求保持连续性,必须记住上一轮对话中用户问过“HashMap线程安全吗”,下一轮才能基于此追问“ConcurrentHashMap怎么保证线程安全”。《码上面试》用Redis Hash结构来存储这个状态,键名设计为agent:state:{sessionId},其中每个field对应一个状态维度:

FieldTypeDescriptionWhy Not Memory?
lastQueryString上次用户输入的原始问题文本JVM重启即丢失,无法支持集群部署
contextIdLong当前上下文ID,用于关联MySQL中的知识溯源记录内存无法与数据库事务联动,一致性难保障
toolHistoryList已调用过的工具名称列表(如"mysql_search", "redis_cache_check")需要序列化为JSON字符串存入Redis,避免List对象序列化兼容性问题

提示:项目里没用Spring Session或RedisTemplate直接操作Hash,而是封装了一个AgentStateRepository接口,实现类RedisAgentStateRepository里所有方法都加了@Transactional注解——这不是多此一举。因为Agent状态更新往往和MySQL知识库查询同时发生(比如查完题后要记录“本次查询命中缓存”),必须保证Redis写入和MySQL日志写入的原子性。我第一次部署时漏掉了这个注解,导致缓存标记写成功但MySQL日志写失败,后续统计“缓存命中率”时数据全乱了。

2.2 KnowledgeSourceManager:不是插件,是可热插拔的知识“器官”

项目正文里没提,但代码里藏着一个精妙设计:KnowledgeSource接口。它定义了Agent获取知识的统一契约:

public interface KnowledgeSource { String getName(); // 如 "mysql_interview_questions" boolean isAvailable(); // 检查MySQL连接是否存活 List<KnowledgeItem> search(String query); // 核心搜索逻辑 void warmUp(); // 启动时预加载热点数据到Redis }

目前实现了两个实现类:MysqlKnowledgeSource和RedisCacheKnowledgeSource。重点在于warmUp()方法——它不是简单地把MySQL全表扫进Redis,而是执行一条精心设计的SQL:

SELECT question_id, question_text, answer_summary FROM interview_questions WHERE difficulty_level IN ('EASY', 'MEDIUM') AND last_updated > DATE_SUB(NOW(), INTERVAL 30 DAY) ORDER BY view_count DESC LIMIT 500;

这条SQL背后有三个硬核考量:

  1. 过滤冷数据:last_updated > 30 DAY排除半年没人问的“Java 1.2特性”类问题,避免缓存污染;
  2. 聚焦高频题:按view_count排序,确保Redis里存的都是真实被面试官高频提问的内容;
  3. 控制体积:LIMIT 500防止启动时Redis内存爆满——我实测过,全量10万条题干+答案塞进去,Redis占用从200MB飙到1.2GB,GC压力剧增。

注意:KnowledgeSourceManager在Spring Boot启动时会遍历所有@Component标注的KnowledgeSource实现类,自动注册到内部Map。这意味着,如果你想新增一个“从PDF解析Java面试题”的知识源,只需新建一个类实现接口,打上@Component,无需改任何已有代码——这才是真正的可扩展性,不是靠配置文件开关。

2.3 Controller层:REST API不是终点,而是Agent的“神经末梢”

AgentController里的/api/v1/ask接口,表面看只是接收JSON、返回JSON,但它的处理链路暴露了Agent的本质:

@PostMapping("/ask") public ResponseEntity<AgentResponse> ask(@RequestBody AgentRequest request) { // Step 1: 从Redis加载或初始化Agent状态 AgentState state = agentStateService.loadOrCreate(request.getSessionId()); // Step 2: 基于当前状态和用户问题,决策调用哪个知识源 KnowledgeSource selectedSource = knowledgeSourceManager.selectSource(state, request.getQuery()); // Step 3: 执行知识检索,并更新状态 List<KnowledgeItem> results = selectedSource.search(request.getQuery()); state.updateWithResults(results); agentStateService.save(state); // Step 4: 构建响应(含状态ID,供前端下次请求携带) return ResponseEntity.ok(new AgentResponse(results, state.getContextId())); }

这段代码里没有LLM调用,没有Function Calling,但它完成了Agent最核心的闭环:感知(接收query)→ 决策(selectSource)→ 行动(search)→ 记忆(updateState)→ 反馈(return response)。我特意对比过LangChain的AgentExecutor源码,发现其核心循环逻辑惊人地相似——区别只在于,LangChain把“决策”交给LLM prompt,而《码上面试》把“决策”交给Java if-else和规则引擎(后续章节会讲)。这印证了一个事实:Agent的范式,本质上是对传统软件工程状态机模式的升级,而非对AI技术的依赖。

3. 数据层攻坚:MySQL与Redis如何协同构建“知识血液循环系统”

Agent的“知识”不是静态文档,而是需要实时流动、动态更新的活体数据。《码上面试》用MySQL存原始、权威的面试题数据(结构化、可事务),用Redis存高频、易变的缓存结果(低延迟、高并发),二者不是主从关系,而是构成了一套精密的“知识血液循环系统”——MySQL是心脏,Redis是毛细血管,中间靠一套严谨的缓存治理策略连接。

3.1 MySQL表结构设计:面向面试场景,而非通用知识库

项目里只有一张核心表interview_questions,但字段设计直击Java面试痛点:

CREATE TABLE `interview_questions` ( `question_id` BIGINT PRIMARY KEY AUTO_INCREMENT, `question_text` TEXT NOT NULL COMMENT '原始问题,如:ArrayList和LinkedList的区别?', `answer_summary` TEXT NOT NULL COMMENT '精炼答案要点,不超过200字', `full_answer` LONGTEXT COMMENT '详细解答,含代码示例', `difficulty_level` ENUM('EASY','MEDIUM','HARD') DEFAULT 'MEDIUM', `category` VARCHAR(50) NOT NULL COMMENT '分类:集合、JVM、并发、Spring等', `view_count` INT DEFAULT 0 COMMENT '被查询次数,用于热度排序', `last_updated` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `source_url` VARCHAR(255) COMMENT '来源链接,如牛客网题目页', INDEX `idx_category_difficulty` (`category`, `difficulty_level`), INDEX `idx_view_count` (`view_count`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这个设计有三个反常识点:

  • 不用JSON字段存答案:虽然MySQL 5.7+支持JSON,但answer_summary和full_answer拆成两个TEXT字段。原因是answer_summary需频繁读取(展示在列表页),而full_answer只在用户点击“查看详情”时才加载——分离存储能减少80%的网络传输量;
  • view_count不设为BIGINT:Java面试题热度有天花板,单题日查询峰值约2000次,INT足够覆盖10年数据,且INT索引比BIGINT小25%,在百万级数据量下,B+树索引深度能少一层;
  • source_url允许NULL:很多经典题(如“String为什么不可变”)没有单一来源,强行填URL反而误导,NULL在这里是语义正确的。

实操心得:我部署时发现idx_view_count索引效果不佳,因为view_count更新太频繁(每次查询都+1),导致索引页分裂严重。后来改成复合索引INDEX idx_hot_topic (category, view_count),按分类聚合排序,既满足“查看Java集合类热门题”的需求,又大幅降低索引维护成本。

3.2 Redis缓存策略:穿透、雪崩、击穿?这里只用“双检锁+逻辑过期”

项目没用Spring Cache的@Cacheable,而是手写了一套更可控的缓存逻辑。以getQuestionById为例:

public KnowledgeItem getQuestionById(Long id) { // Step 1: 先查Redis(带逻辑过期时间) String cacheKey = "question:" + id; String json = redisTemplate.opsForValue().get(cacheKey); if (json != null && !isLogicExpired(json)) { // 自定义逻辑过期判断 return JSON.parseObject(json, KnowledgeItem.class); } // Step 2: 双检锁防穿透 String lockKey = "lock:question:" + id; Boolean isLocked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 3, TimeUnit.SECONDS); if (!Boolean.TRUE.equals(isLocked)) { // 等待100ms后重试,避免雪崩 Thread.sleep(100); return getQuestionById(id); } try { // Step 3: 查MySQL KnowledgeItem item = mysqlMapper.selectById(id); if (item == null) { // 缓存空对象,防穿透 redisTemplate.opsForValue().set(cacheKey, "null", 2, TimeUnit.HOURS); return null; } // Step 4: 写入Redis,设置逻辑过期时间(非TTL) String cacheData = JSON.toJSONString(item); redisTemplate.opsForValue().set(cacheKey, cacheData + "|EXPIRE|" + System.currentTimeMillis() + 3600000); return item; } finally { redisTemplate.delete(lockKey); } }

这套方案规避了教科书式的三大问题:

  • 穿透:空对象缓存2小时,且key命名带question:前缀,避免恶意构造不存在ID;
  • 雪崩:逻辑过期时间(System.currentTimeMillis() + 3600000)让缓存失效时间分散,不会集体到期;
  • 击穿:双检锁+短时锁(3秒)保证同一ID的并发请求只有一个穿透到DB。

踩坑记录:最初用setex设置物理TTL,结果遇到Redis RDB快照期间大量key集中过期,CPU飙升。换成逻辑过期后,通过定时任务扫描question:*keys并清理过期项,CPU负载下降60%。这再次证明:Agent的稳定性,往往藏在数据库和缓存的细节里,而非模型参数中。

3.3 数据一致性终极方案:Binlog监听 + Canal同步

当面试官更新了题库(比如修正一道ConcurrentHashMap的错误答案),MySQL数据变了,但Redis缓存还是旧的,怎么办?项目采用Canal监听MySQL Binlog,实现准实时同步:

  1. Canal Server配置监听interview_questions表的UPDATE事件;
  2. 自定义Canal Client收到事件后,解析出question_id;
  3. 执行redisTemplate.delete("question:" + questionId),强制缓存失效;
  4. 下一次查询自然触发回源加载新数据。

这个方案比“更新MySQL后手动删Redis key”更可靠,因为:

  • 它不依赖业务代码的健壮性(万一开发者忘了删缓存?);
  • 它能捕获所有数据变更渠道(包括DBA直接SQL更新、ETL工具导入);
  • Canal Client可水平扩展,监听多个表,形成统一的数据同步中枢。

我实测过:MySQL执行UPDATE interview_questions SET answer_summary='...' WHERE question_id=123;,平均230ms后Redis对应key消失。这个延迟完全在面试题场景可接受范围内——毕竟没人会秒级刷新面试题答案。

4. Agent执行引擎:从规则驱动到混合决策的演进路径

《码上面试》的Agent执行逻辑,不是一成不变的。它在v1.0版本用纯规则匹配(if-else),v1.2引入轻量级规则引擎Drools,v1.5则尝试集成小型语言模型(TinyLLM)做意图识别——这种渐进式演进,恰恰揭示了Agent落地的真实路径:先用确定性逻辑跑通闭环,再用概率性模型提升体验,而非一上来就押注黑盒模型。

4.1 规则驱动阶段:正则表达式是Agent的“初级神经元”

早期版本的KnowledgeSourceSelector长这样:

public KnowledgeSource selectSource(AgentState state, String query) { if (query.matches(".*(集合|ArrayList|LinkedList|HashMap|ConcurrentHashMap).*")) { return mysqlKnowledgeSource; } else if (query.matches(".*(JVM|垃圾回收|内存模型|OOM).*")) { return mysqlKnowledgeSource; } else if (query.matches(".*(Redis|缓存|淘汰策略|持久化).*")) { return redisCacheKnowledgeSource; } else { return fallbackKnowledgeSource; // 返回通用FAQ } }

这段代码看似简陋,但解决了80%的场景。它的优势在于:

  • 100%可预测:给定问题“HashMap和HashTable的区别?”,永远走MySQL分支,不会因模型随机性返回错误知识源;
  • 零依赖:不需GPU、不需模型下载、不需网络请求,部署成本趋近于零;
  • 可调试:在IDE里打断点,一眼看出哪个正则匹配了,哪条规则被触发。

经验技巧:正则表达式别写太复杂。我最初用(?i)(hash.*map|linked.*list)试图覆盖所有变体,结果“哈希映射”中文词匹配失败。后来改成白名单关键词匹配(query.contains("HashMap") || query.contains("哈希映射")),准确率反而从72%升到94%。Agent的“智能”,有时就是把简单事情做扎实。

4.2 Drools规则引擎:让决策逻辑脱离代码,进入可配置时代

当规则超过20条,if-else就变成维护噩梦。项目引入Drools后,决策逻辑移到rules.drl文件:

rule "Java集合类问题" when $q: Question(query : text matches "(?i).*(集合|ArrayList|HashMap|ConcurrentHashMap).*") then $q.setSource("mysql_interview_questions"); $q.setConfidence(0.95); end rule "Redis缓存问题" when $q: Question(query : text matches "(?i).*(Redis|缓存穿透|雪崩|击穿).*") then $q.setSource("redis_cache_knowledge"); $q.setConfidence(0.85); end rule "低置信度默认路由" when $q: Question(confidence < 0.5) then $q.setSource("fallback_faq"); end

Drools带来的质变:

  • 业务与开发解耦:HR团队可以自己编辑.drl文件,增加“Spring Boot面试题”规则,无需Java工程师改代码;
  • 置信度量化:每条规则输出confidence值,为后续引入LLM做融合决策留出接口;
  • 冲突解决:当一个问题同时匹配“集合”和“JVM”规则时,Drools按salience(优先级)自动选择,避免逻辑混乱。

4.3 混合决策架构:LLM不是替代者,而是“规则增强器”

最新版项目保留Drools作为主决策引擎,但增加一个LLMIntentRecognizer组件:

// 在Drools决策后调用 double baseConfidence = droolsResult.getConfidence(); if (baseConfidence < 0.7) { // 用TinyLLM做二次校验 String llmPrompt = "请判断以下问题属于哪个技术领域:'" + query + "'。选项:Java集合、JVM、并发、Spring、Redis、MySQL。只返回领域名称,不要解释。"; String llmDomain = tinyLLM.generate(llmPrompt); if (llmDomain.equals("Java集合")) { droolsResult.setConfidence(baseConfidence * 1.2); // 置信度提升 droolsResult.setSource("mysql_interview_questions"); } }

这个设计的精妙在于:

  • LLM只负责意图识别,不生成答案:避免模型幻觉污染知识库;
  • LLM输出作为权重调节因子,不颠覆规则结果:即使LLM错了,最终决策仍由Drools兜底;
  • TinyLLM模型仅12MB:用ONNX Runtime在CPU上运行,无需GPU,推理延迟<150ms。

我对比过纯LLM方案:在1000条测试问题中,纯LLM准确率89%,但有7%的“幻觉路由”(把“HashMap”问题路由到MySQL,却把“ConcurrentHashMap”路由到Redis);而混合方案准确率93%,且0次幻觉路由——因为规则引擎始终是最后一道防线。

5. 本地开发避坑指南:那些让Java工程师抓狂的“环境刺客”

把《码上面试》跑起来,最大的敌人不是代码,而是环境。我整理了本地开发时踩过的7个深坑,按致命程度排序,全是血泪教训:

5.1 MySQL 8.0 SSL连接错误:ERROR 2002 (HY000): Can't connect to local MySQL server through socket '/tmp/mysql.sock'

现象:Spring Boot启动报错,提示找不到socket文件。
根因:Mac OS默认安装MySQL用Homebrew,socket路径是/opt/homebrew/var/mysql/mysql.sock,而Spring Boot JDBC URL默认找/tmp/mysql.sock。
解决方案:

  • 方案1(推荐):修改application.yml,显式指定socket路径:
    spring: datasource: url: jdbc:mysql://localhost:3306/interview_db?unixSocket=/opt/homebrew/var/mysql/mysql.sock
  • 方案2:创建软链接sudo ln -s /opt/homebrew/var/mysql/mysql.sock /tmp/mysql.sock(需sudo权限)。

注意:千万别用skip-grant-tables绕过认证!这会让MySQL以不安全模式运行,Agent的view_count更新可能被恶意脚本刷爆。

5.2 Redis Desktop Manager连接失败:ERR Client sent AUTH, but no password is set

现象:Redis CLI能连,但图形客户端连不上。
根因:项目application.yml里配置了spring.redis.password=secret123,但Redis Desktop Manager默认不发送AUTH命令。
解决方案:在连接设置里勾选“Use password authentication”,输入secret123。
延伸问题:如果忘记密码,别慌,用redis-cli执行:

redis-cli -a secret123 CONFIG SET requirepass ""

立刻清空密码(生产环境严禁此操作)。

5.3 Maven编译失败:Could not resolve dependencies for project com.mashang:ma-shang-interview:jar:1.0-SNAPSHOT

现象:mvn clean install卡在Downloading from central。
根因:国内网络访问Maven Central慢,且项目pom.xml里没配阿里云镜像。
解决方案:在~/.m2/settings.xml里添加:

<mirrors> <mirror> <id>aliyunmaven</id> <mirrorOf>*</mirrorOf> <name>Aliyun Maven</name> <url>https://maven.aliyun.com/repository/public</url> </mirror> </mirrors>

5.4 Navicat破解版SSL连接错误:MySQL SSL connection error

现象:Navicat连MySQL报SSL握手失败。
根因:MySQL 8.0默认启用caching_sha2_password插件,而老版Navicat不支持。
解决方案:

  • 方案1(安全):升级Navicat到16.0+;
  • 方案2(临时):在MySQL中执行:
    ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'your_password'; FLUSH PRIVILEGES;
    (注意:这会降低密码安全性,仅限本地开发)

5.5 Docker安装Redis主从后,Agent连不上从节点

现象:redisTemplate读取缓存总是超时。
根因:Docker网络中,从节点IP不是localhost,而项目配置里写死了spring.redis.host=localhost。
解决方案:

  • 修改application.yml,用Docker服务名:
    spring: redis: host: redis-slave # 对应docker-compose.yml里的service name port: 6379
  • 或者干脆在docker-compose.yml里把主从都映射到宿主机端口,用127.0.0.1:6380连从节点。

5.6 Java POI Word生成图表失败:java.lang.NoClassDefFoundError: org/openxmlformats/schemas/drawingml/x2006/chart/CTChart

现象:调用WordExporter.exportToWord()时报错。
根因:POI 5.2.0+版本拆分了chart模块,需单独引入依赖。
解决方案:在pom.xml里加:

<dependency> <groupId>org.apache.poi</groupId> <artifactId>poi-ooxml-schemas</artifactId> <version>4.1.2</version> </dependency> <dependency> <groupId>org.apache.xmlbeans</groupId> <artifactId>xmlbeans</artifactId> <version>5.1.0</version> </dependency>

5.7 Linux安装MySQL后,mysqld: Can't read dir of '/etc/my.cnf.d/'

现象:MySQL服务启动失败。
根因:CentOS 7默认安装MariaDB,残留配置文件冲突。
解决方案:

  • 彻底卸载MariaDB:sudo yum remove mariadb-libs;
  • 清理配置目录:sudo rm -rf /etc/my.cnf.d/;
  • 重新安装MySQL:sudo yum install mysql-community-server。

这些坑,每一个都让我在深夜对着终端发呆超过半小时。但填平它们的过程,恰恰是理解Agent项目真实落地复杂度的最佳路径——它不光是写代码,更是和操作系统、数据库、网络、容器层层较劲。当你终于看到curl -X POST http://localhost:8080/api/v1/ask -d '{"query":"HashMap线程安全吗?","sessionId":"test123"}'返回JSON里带着正确答案时,那种成就感,远胜于跑通任何LLM demo。因为你知道,这行代码背后,是MySQL的事务、Redis的原子操作、Spring的IoC容器、Linux的进程调度,共同托起了一个最朴素的Agent梦想:让知识,真正“码上”可用。