ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

BizBuddy:面向Java工程师的生产级AI Agent运行时平台

BizBuddy:面向Java工程师的生产级AI Agent运行时平台

1. BizBuddy 不是玩具框架:它要解决的,是 Java 工程师在 AI 时代最真实的交付焦虑

你有没有经历过这样的场景:业务方拿着一份“智能客服升级需求”来找你,说“我们要接入大模型能力,支持多轮对话、知识库检索、自动工单生成”。你点头答应,心里却在飞速盘算——API 调用怎么封装?历史会话怎么存?工具调用失败要不要重试?函数参数校验谁来做?权限控制加在哪一层?日志里怎么区分是用户输入、LLM 输出、还是工具执行结果?更糟的是,上线后发现某个 Agent 在高并发下内存泄漏,但堆栈里全是 LLM SDK 的异步回调,根本没法定位。

BizBuddy 就是在这种焦灼中诞生的。它不是又一个“Java + LangChain 翻译版”的玩具 Demo,而是一个从 RuoYi-Vue-Plus 这类真实企业级后台系统里长出来的、带伤疤的工业级 Agent 运行时平台。关键词里没有写,但它的基因里刻着三件事:强类型约束、可审计链路、可灰度发布。它不追求“支持 100 种 Agent 框架”,而是死磕“让一个 Java 工程师能在三天内,把一个业务逻辑清晰的 Agent(比如合同条款比对)安全地上线到生产环境,并能查清每一次调用的完整上下文”。

这决定了 BizBuddy 的所有设计取舍都围绕一个核心矛盾展开:AI 的不确定性 vs Java 生态的确定性。LangChain 的 Chain 是动态拼接的,AgentScope 的 Agent 是 YAML 驱动的,而 BizBuddy 的 Agent 必须是一个@Component注解的 Spring Bean,它的输入输出类型必须在编译期就明确,它的生命周期必须由 Spring 容器管理,它的错误必须能被@ExceptionHandler捕获。这不是守旧,而是当你的 Agent 要对接核心财务系统、要生成法律文书、要触发银行支付时,你唯一能抓住的救命稻草。

所以,当你看到“纯 Java”这个限定词时,请别理解为技术保守主义。它意味着:没有 Groovy 脚本注入风险,没有 YAML 解析导致的反序列化漏洞,没有运行时动态类加载带来的 ClassLoader 冲突。BizBuddy 的每个模块,都像 MyBatis-Plus 的LambdaQueryWrapper一样,用泛型和注解把意图编译进字节码里。它要的不是“能跑”,而是“跑得稳、看得清、改得准、审得过”。

2. Harness 的本质不是“套壳”,而是定义一套可验证的契约:BizBuddy 的四层抽象模型

很多团队把 “Agent Harness” 理解成给 LLM API 套个 Spring Boot 外壳,加个/v1/chat/completions的 Controller 就完事。BizBuddy 把这层外壳撕开了,暴露出四个不可绕过的抽象层。它们不是理论模型,而是我在汇付天下做跨境支付风控 Agent 时,被线上事故逼出来的结构。

2.1 第一层:Orchestration Layer(编排层)——用 Java 接口定义 Agent 的“宪法”

BizBuddy 不允许任何 Agent 直接调用 OpenAI 或 Qwen 的 SDK。所有对外交互,必须通过AgentExecutor接口:

public interface AgentExecutor<T extends AgentInput, R extends AgentOutput> { /** * 执行 Agent 核心逻辑。此方法必须是幂等的、无状态的。 * 实现类需保证:同一输入,在相同配置下,输出结果可复现。 */ R execute(T input) throws AgentExecutionException; /** * 获取该 Agent 的元信息。用于平台统一注册、路由、监控。 * 返回值必须是不可变对象,且字段全部为 final。 */ AgentMetadata getMetadata(); }

注意execute()方法的签名:T extends AgentInput,R extends AgentOutput。这不是泛泛而谈的Object,而是强制要求每个 Agent 必须定义自己的输入输出 DTO。比如一个“发票识别 Agent”:

@Data @Builder public class InvoiceRecognitionInput implements AgentInput { @NotBlank(message = "图片 URL 不能为空") private String imageUrl; @Min(value = 100, message = "置信度阈值不能低于 100") private Integer confidenceThreshold; } @Data @Builder public class InvoiceRecognitionOutput implements AgentOutput { private String invoiceNumber; private BigDecimal amount; private List<LineItem> lineItems; // ... 其他结构化字段 }

这个设计直接砍掉了两个常见坑:一是避免了 JSON 字符串在 Controller 层和 Service 层之间反复序列化/反序列化带来的性能损耗和类型丢失;二是让输入校验(@NotBlank,@Min)能天然集成到 Spring Validation 中,错误信息能精准返回给前端,而不是一堆模糊的500 Internal Server Error。

提示:BizBuddy 的AgentInput和AgentOutput接口本身不包含任何字段,只作为类型标记。真正的约束靠子类上的 JSR-303 注解实现。这比用@Valid注解一个Map<String, Object>严谨得多。

2.2 第二层:Tooling Layer(工具层)——把“调用外部服务”变成可插拔的 Java 方法

Agent 的“工具调用”(Tool Calling)在 Python 生态里常是用@tool装饰器或 YAML 配置。BizBuddy 的方案是:每个 Tool 必须是一个标准的 Spring@ServiceBean,且其方法签名必须严格匹配FunctionCall协议。

我们定义了一个ToolDefinition类,它描述了一个 Tool 的元数据:

@Data @Builder public class ToolDefinition { private String name; // 必须与 LLM 返回的 function_call.name 一致 private String description; // 供 LLM 理解用途 private Map<String, ToolParameter> parameters; // 参数定义,用于生成 JSON Schema private String beanName; // Spring 容器中对应的 Bean 名称 private String methodName; // Bean 中对应的方法名 }

而一个真实的 Tool 实现长这样:

@Service("invoiceSearchTool") public class InvoiceSearchTool { @Autowired private InvoiceDao invoiceDao; /** * 此方法将被 BizBuddy 的 ToolExecutor 反射调用。 * 参数名、类型、顺序必须与 ToolDefinition.parameters 完全一致。 * 返回值必须是 JSON 可序列化的 POJO。 */ public InvoiceSearchResult searchInvoices( @NotBlank String supplierName, @NotNull @Past LocalDate startDate, @NotNull @Future LocalDate endDate) { return invoiceDao.findBySupplierAndDateRange(supplierName, startDate, endDate); } }

关键点在于:searchInvoices方法的参数列表,就是ToolDefinition.parameters的来源。BizBuddy 启动时会扫描所有@ServiceBean,根据@Bean注解的名称(如"invoiceSearchTool")和方法名("searchInvoices"),自动生成ToolDefinition并注册到全局ToolRegistry。LLM 返回{"name": "invoiceSearchTool", "arguments": {"supplierName": "XX公司", "startDate": "2024-01-01", "endDate": "2024-06-01"}}后,平台能精准定位到InvoiceSearchTool.searchInvoices()方法,并用 Jackson 将argumentsJSON 反序列化为方法参数。

这解决了 Python 生态里常见的“参数类型错位”问题。比如 LLM 返回{"amount": "1000"},Python 里可能被当成字符串传给需要int的函数。而在 Java 里,Jackson 会尝试将字符串"1000"转为Integer,失败则抛出JsonMappingException,这个异常会被 BizBuddy 捕获并记录为TOOL_ARGUMENT_PARSE_ERROR,而不是让整个 Agent 流程静默失败。

2.3 第三层:State Management Layer(状态管理层)——用 ThreadLocal + Redis 的混合模式保活会话

Agent 的“记忆”(Memory)是另一个深坑。纯内存的ConcurrentHashMap<String, List<Message>>在单机部署时没问题,但一上 K8s,Pod 重启或扩缩容,会话就丢了。全放 Redis 又太重,每次getMessages()都要网络 IO。

BizBuddy 的方案是分层状态管理:

  • 短期状态(< 5 分钟):存在ThreadLocal<AgentSession>里。AgentSession是一个轻量级对象,只存最近 3 条消息的摘要(Message ID、Role、Content Hash)。这是为了应对同一个 HTTP 请求链路内的多次 LLM 调用(比如一次 Agent 执行中需要调用 3 次 LLM),避免重复序列化。

  • 中期状态(5 分钟 ~ 7 天):存在 Redis 的HASH结构里,Key 是agent:session:{sessionId},Field 是messages(JSON 字符串)、lastAccessTime(毫秒时间戳)、version(乐观锁版本号)。BizBuddy 提供RedisSessionStore实现,所有读写都带WATCH/MULTI/EXEC事务,防止并发修改冲突。

  • 长期归档(> 7 天):自动触发异步任务,将HASH中的数据转存到 MySQL 的agent_session_archive表,并删除 Redis 中的记录。归档表有sessionId,messages_json,createdAt,archivedAt字段,支持按业务维度(如tenant_id)分区。

这个设计的精妙之处在于:AgentSession对象本身是不可变的(Immutable),它的addMessage()方法返回一个新对象,而不是修改自身。这保证了ThreadLocal中的状态不会被意外污染。而 Redis 的WATCH机制,则确保了在高并发下,两个请求同时想更新同一个会话的lastAccessTime,只有一个能成功,另一个会重试。

注意:BizBuddy 的AgentSession不存储原始的Message对象,而是存储MessageSnapshot,它只保留id,role,content(截断前 200 字符),以及toolCalls的摘要。完整的Message对象只存在于 MySQL 归档表中。这是为了平衡性能与审计需求。

2.4 第四层:Observability Layer(可观测性层)——把每一次 LLM 调用变成可审计的“电子凭证”

在金融、政务类项目里,“谁在什么时候,让 Agent 做了什么,依据是什么,结果是什么”,必须能回溯。BizBuddy 的AgentExecutionTrace就是为此而生。

每次AgentExecutor.execute()被调用,BizBuddy 都会生成一个唯一的traceId(UUID),并创建一个AgentExecutionTrace对象,它包含:

  • traceId: 全局唯一标识
  • sessionId: 关联的会话 ID
  • input: 序列化后的AgentInputJSON(脱敏处理,如手机号138****1234)
  • llmRequest: 发送给 LLM 的完整 Prompt(含 System Message、History、Tools Schema)
  • llmResponse: LLM 返回的原始 JSON(含choices[0].message.content和choices[0].message.tool_calls)
  • toolResults: 所有 Tool 调用的输入、输出、耗时、状态(SUCCESS/FAILED)
  • output: 最终AgentOutput的 JSON
  • status:SUCCESS,LLM_TIMEOUT,TOOL_FAILED,VALIDATION_ERROR等
  • durationMs: 总耗时(毫秒)

这个AgentExecutionTrace对象,会被同步写入 Elasticsearch(用于实时搜索和告警),异步写入 MySQL(用于长期存储和报表),并发送到 Kafka(供下游风控系统消费)。

最关键的是,llmRequest和llmResponse字段,是经过严格格式化和脱敏的。BizBuddy 内置一个PromptSanitizer,它会:

  • 移除所有\n和多余空格,将 Prompt 压缩成一行,便于日志检索;
  • 对input中的敏感字段(如身份证号、银行卡号)进行正则匹配和掩码;
  • 记录llmRequest的model、temperature、max_tokens等参数,确保“同输入、同参数、同输出”的可复现性。

这使得审计员可以输入一个traceId,在 Kibana 里瞬间看到:这个 Agent 当时看到了什么上下文,调用了哪些工具,工具返回了什么,最终输出了什么。而不是面对一堆零散的INFO日志,手动拼凑。

3. 为什么放弃 AgentScope 的 YAML 驱动?BizBuddy 的“Java First”哲学与代价

AgentScope 是个优秀的设计,它的 YAML 配置让非 Java 工程师也能快速定义 Agent。但 BizBuddy 明确放弃了这条路。这不是技术偏见,而是我们在实际交付中,为三个无法妥协的硬性指标付出的必要代价。

3.1 指标一:编译期类型安全 —— 避免“运行时才发现字段名写错”

想象一个场景:业务方提供了一份 Excel 表格,定义了“合同审核 Agent”的输入字段:contractText,signatoryName,signatoryIdCard。开发同学照着写 YAML:

input_schema: type: object properties: contract_text: {type: string} signatory_name: {type: string} signatory_id_card: {type: string}

然后在 Java 代码里,他写了:

public class ContractReviewInput { private String contractText; // 注意:这里是 camelCase private String signatoryName; private String signatoryIdCard; }

YAML 里的contract_text和 Java 里的contractText,Jackson 默认是能映射的。但问题来了:如果业务方后来改了 Excel,新增了一个reviewerDepartment字段,开发同学只改了 YAML,忘了在 Java 类里加字段。那么当 LLM 返回{"reviewerDepartment": "法务部"}时,Jackson 会静默忽略这个字段,ContractReviewInput对象里永远是null。而这个null,可能在后续的规则引擎里导致空指针异常,或者更糟——被当成默认值"",触发错误的审批流程。

BizBuddy 的解决方案是:输入输出 DTO 必须是 Java 类,且必须用@JsonProperty显式声明 JSON 字段名:

@Data @Builder public class ContractReviewInput implements AgentInput { @JsonProperty("contract_text") private String contractText; @JsonProperty("signatory_name") private String signatoryName; @JsonProperty("signatory_id_card") private String signatoryIdCard; @JsonProperty("reviewer_department") // 新增字段,必须同步加 private String reviewerDepartment; }

这样,一旦 YAML 和 Java 类不一致,编译就会失败(如果用了 Lombok 的@Builder,字段缺失会导致构造器报错),或者启动时报Unrecognized field异常。错误被提前到了离开发者最近的地方,而不是在凌晨三点的生产环境告警里。

3.2 指标二:IDE 可导航性 —— 让“跳转到定义”成为排查第一利器

在 AgentScope 的 YAML 世界里,当你看到tool: "invoice_search",你想知道这个 Tool 具体做了什么,得:

  1. 在 YAML 文件里找到invoice_search的定义,看它指向哪个function;
  2. 然后去代码里搜索那个function名字;
  3. 如果名字是search_invoices,还得猜它在哪个包里;
  4. 最后打开文件,找到方法。

而在 BizBuddy 里,你在AgentExecutor的execute()方法里,看到toolExecutor.invoke("invoiceSearchTool", ...),光标放在"invoiceSearchTool"上,按Ctrl+Click(IntelliJ)或F12(VS Code),直接跳转到InvoiceSearchTool类。再按一次,跳转到searchInvoices()方法。整个过程不到 1 秒。

更重要的是,IDE 能帮你做重构。如果你要把InvoiceSearchTool改名为InvoiceQueryService,只需要对类名右键Refactor -> Rename,IDE 会自动更新所有@Service("invoiceSearchTool")注解里的beanName,以及所有toolExecutor.invoke("invoiceSearchTool", ...)的字符串。而 YAML 里的tool: "invoice_search",IDE 是完全无感的,你得手动全局搜索替换,漏掉一个就埋下隐患。

3.3 指标三:Spring 生态无缝集成 —— 把 Agent 当成一个普通的 Service 来管

BizBuddy 的AgentExecutor是一个标准的 Spring@Service。这意味着:

  • 它可以被@Transactional注解,确保 Agent 执行中的数据库操作要么全成功,要么全回滚;
  • 它可以被@Cacheable注解,对相同输入的execute()调用进行缓存(比如“汇率查询 Agent”);
  • 它可以被@Retryable注解,在 LLM API 临时不可用时自动重试;
  • 它的依赖(如InvoiceDao,RedisTemplate)可以由 Spring 自动注入,无需自己 new 对象;
  • 它的生命周期由 Spring 管理,@PostConstruct和@PreDestroy可以用来做初始化和清理。

而 AgentScope 的 YAML Agent,本质上是一个运行时解析的配置对象。你想给它加事务?得自己写 AOP 切面,还要处理@Transactional在代理对象上的失效问题。你想让它用上 MyBatis-Plus 的LambdaQueryWrapper?得在 YAML 里写一堆字符串拼接的 SQL,失去了类型检查和 IDE 提示。

放弃 YAML 的代价是:定义一个新 Agent,要写更多的 Java 代码。但换来的是:每一个 Agent,都享有整个 Spring 生态的成熟能力和工程保障。对于一个要承载千万级交易的企业平台,这个代价,我们认为非常值得。

4. 从 RuoYi-Vue-Plus 到 BizBuddy:如何把一个后台管理系统,变成 Agent 的“生产车间”

BizBuddy 的原型,就长在 RuoYi-Vue-Plus 这个成熟的后台管理系统里。这不是巧合,而是刻意为之。因为一个真正能落地的 Agent 平台,必须能无缝嵌入到现有企业的 IT 架构中,而不是另起炉灶。

4.1 用户与权限体系:复用 RuoYi 的 RBAC,让 Agent 也受控

RuoYi-Vue-Plus 有一套完善的用户、角色、菜单、权限管理。BizBuddy 没有重造轮子,而是扩展了它:

  • 新增sys_agent表:存储 Agent 的基本信息(agent_code,agent_name,description,status,created_by,created_time)。
  • 新增sys_agent_permission表:关联sys_role和sys_agent,定义“某个角色能调用哪些 Agent”。
  • 在 RuoYi 的SysPermissionService里,增加一个checkAgentPermission(String agentCode, Long userId)方法,它会查询用户所属角色拥有的 Agent 权限。

这样,当一个前端页面(比如“智能合同中心”)要调用ContractReviewAgent时,它的 Vue 组件会先调用/api/agent/permission?agentCode=contract-review接口。这个接口背后,就是SysPermissionService.checkAgentPermission()。如果没权限,直接返回403 Forbidden,前端显示“您无权使用此功能”。

提示:BizBuddy 的AgentExecutor在execute()方法开头,会自动调用SecurityContextUtil.getCurrentUserId()获取当前用户 ID,并通过AgentPermissionService校验权限。这层校验是切面(AOP)实现的,对 Agent 开发者透明。

4.2 配置中心:用 Nacos 替代 YAML,让 Agent 配置可动态刷新

虽然 BizBuddy 放弃了 YAML 定义 Agent,但它没有放弃配置的灵活性。Agent 的一些运行时参数,比如 LLM 的temperature、max_tokens、Tool 的超时时间,都是可配置的。

BizBuddy 使用 Nacos 作为配置中心。每个 Agent 对应一个 Data ID,格式为agent-config-{agentCode}.properties。例如agent-config-contract-review.properties:

llm.model=qwen-max llm.temperature=0.3 llm.max_tokens=2048 tool.invoiceSearchTool.timeoutMs=5000

BizBuddy 的AgentConfigManager会监听 Nacos 的变更。当temperature从0.3改为0.1时,AgentConfigManager会立即刷新内存中的配置缓存。下次ContractReviewAgent.execute()被调用时,就会使用新的temperature值。

这比重启应用要快得多,也比改 Java 代码要安全得多。运维人员可以在 Nacos 控制台里,针对不同环境(dev/test/prod)设置不同的配置,甚至可以做灰度:先给 10% 的流量设置temperature=0.1,观察效果,再全量。

4.3 监控与告警:把 Agent 指标打到 Prometheus,和现有大盘融合

RuoYi-Vue-Plus 的监控页面,已经集成了 Prometheus 和 Grafana。BizBuddy 的指标,必须能无缝融入这个大盘。

BizBuddy 使用 Micrometer,暴露了以下关键指标:

  • bizbuddy_agent_execution_total{agent_code, status}:Agent 执行总数,按agent_code和status(SUCCESS/FAILED)打点。
  • bizbuddy_agent_execution_duration_seconds{agent_code, status}:Agent 执行耗时直方图。
  • bizbuddy_llm_request_total{model, status}:LLM 请求总数,按模型和状态(200/429/500)打点。
  • bizbuddy_tool_invocation_total{tool_name, status}:Tool 调用总数。

这些指标,通过 Spring Boot Actuator 的/actuator/prometheus端点暴露。Grafana 里,运维同事只需要新建一个 Panel,选择bizbuddy_agent_execution_total,按agent_code分组,就能看到每个 Agent 的调用量趋势。再叠加rate(bizbuddy_agent_execution_total[5m]),就能看到每分钟的 QPS。

最实用的一个看板是“Agent 健康度”。它计算每个 Agent 的success_rate = success_count / total_count,并设置一个阈值(比如 99.5%)。当某个 Agent 的成功率跌破阈值,Grafana 就会触发告警,通知到企业微信机器人。这个告警,和 RuoYi 系统本身的“数据库连接池满”告警,出现在同一个群聊里,运维不用切换上下文。

4.4 发布与回滚:用 Jenkins Pipeline,把 Agent 当成一个微服务来发布

在 BizBuddy 的 CI/CD 流程里,一个 Agent 的发布,就是一个标准的 Spring Boot 微服务发布:

  1. 开发者提交代码到 Git 仓库(分支feature/agent-contract-review);
  2. Jenkins 触发 Pipeline,执行mvn clean package -Dmaven.test.skip=true;
  3. 构建出bizbuddy-agent-contract-review-1.0.0.jar;
  4. Pipeline 调用 Ansible 脚本,将 JAR 包推送到目标服务器的/opt/bizbuddy/agents/目录;
  5. 执行systemctl restart bizbuddy-agent-contract-review.service;
  6. 启动后,服务会向 Nacos 注册自己,并上报agent_code=contract-review的元数据。

回滚同样简单:Jenkins Pipeline 里有一个“回滚”按钮,点击后,它会从备份目录里,把上一个版本的 JAR 包复制过来,再执行systemctl restart。

这个流程,和 RuoYi-Vue-Plus 的后端服务ruoyi-framework的发布流程,完全一致。运维团队不需要学习新的发布规范,开发团队也不需要为 Agent 写一套特殊的部署脚本。Agent,就是 RuoYi 生态里的一个普通成员。

5. 踩过的坑与血泪经验:那些文档里不会写的 BizBuddy 实战细节

BizBuddy 从 0 到 1 的过程,踩过不少坑。这些坑,往往不在技术选型文档里,而是在深夜的线上故障、在 Code Review 的争论、在压测报告的红色数字里。我把最痛的三个,分享给你。

5.1 坑一:LLM 的stream模式与 Spring WebFlux 的“假流式”陷阱

一开始,我们想用 WebFlux 的Flux<ServerSentEvent>来实现 LLM 的流式响应(Stream),让用户看到“打字机”效果。代码看起来很美:

@GetMapping("/chat/stream") public Flux<ServerSentEvent<String>> streamChat(@RequestBody ChatRequest request) { return agentExecutor.streamExecute(request) .map(chunk -> ServerSentEvent.builder(chunk).build()); }

但实测发现,前端收到的第一个chunk,总是延迟 2~3 秒。抓包一看,HTTP 响应头里Transfer-Encoding: chunked是对的,但第一个 TCP 包,要等 LLM 返回第一个 token 后才发出。问题出在agentExecutor.streamExecute()的实现上。

我们最初是这样写的:

// 错误示范! public Flux<String> streamExecute(ChatRequest request) { // 1. 构造 Prompt // 2. 调用 LLM SDK 的 stream() 方法,返回一个 Flux // 3. 对 Flux 做 map 转换 return llmClient.stream(prompt) .map(this::processChunk); // processChunk 里有耗时的 JSON 解析 }

processChunk()方法里,我们要把 LLM 返回的原始Delta对象,解析成前端需要的String。这个解析(比如delta.getContent())是同步阻塞的。WebFlux 的线程模型是:Flux的每个onNext事件,都在同一个Schedulers.parallel()线程池里执行。processChunk()一卡,后面的chunk就全堵住了。

解决方案:把耗时操作移到publishOn()之后:

// 正确示范 public Flux<String> streamExecute(ChatRequest request) { return llmClient.stream(prompt) .publishOn(Schedulers.boundedElastic()) // 切换到弹性线程池 .map(this::processChunk) // 在弹性线程池里做解析 .publishOn(Schedulers.immediate()); // 切换回主线程,避免线程切换开销 }

boundedElastic()是专门为阻塞 IO 设计的线程池,它能动态扩容,不会像parallel()那样因为一个慢操作拖垮整个流。这个改动,让首包延迟从 2500ms 降到了 200ms。

经验:WebFlux 不是银弹。凡是涉及 JSON 解析、数据库查询、文件 IO 的操作,都必须publishOn到合适的线程池。不要相信“Reactive 就一定快”。

5.2 坑二:Redis 的WATCH在高并发下的“伪乐观锁”

前面提到,BizBuddy 用 Redis 的WATCH/MULTI/EXEC来保证AgentSession更新的原子性。但在一次压测中,我们模拟 1000 QPS 的会话更新,发现EXEC的失败率高达 15%。日志里全是WatchMismatchException。

原因在于:WATCH监控的是 key 的版本(version),而 Redis 的WATCH是基于 key 的“最后修改时间戳”。当多个客户端几乎同时WATCH同一个 key,然后MULTI,再EXEC,Redis 会检查在WATCH之后,key 是否被其他客户端修改过。如果修改过,EXEC就失败。

在高并发下,这个“修改过”的概率很高。我们的修复方案是:不依赖WATCH,改用 Lua 脚本。

-- update_session.lua local sessionId = KEYS[1] local field = ARGV[1] local value = ARGV[2] local version = tonumber(ARGV[3]) -- 用 HGET 获取当前 version local currentVersion = redis.call('HGET', 'agent:session:'..sessionId, 'version') if tonumber(currentVersion) ~= version then return 0 -- 版本不匹配,失败 end -- 更新字段和 version redis.call('HSET', 'agent:session:'..sessionId, field, value) redis.call('HINCRBY', 'agent:session:'..sessionId, 'version', 1) return 1

Java 里调用:

Long result = (Long) redisTemplate.execute(updateSessionScript, Collections.singletonList(sessionId), field, value, String.valueOf(expectedVersion)); if (result == 0) { throw new SessionVersionConflictException(); }

Lua 脚本在 Redis 服务端原子执行,不存在竞态条件。压测后,失败率降为 0。代价是:Lua 脚本的编写和调试比WATCH复杂,但它带来了确定性。

5.3 坑三:MyBatis-Plus 的insertBatch在大批量 Agent 归档时的 OOM

Agent 执行日志的归档,是批量插入 MySQL。我们用了 MyBatis-Plus 的saveBatch()方法,一次插入 1000 条AgentExecutionTrace。

但当单次归档量达到 5000 条时,JVM 的老年代(Old Gen)会频繁 GC,最终java.lang.OutOfMemoryError: GC overhead limit exceeded。

根源在于:saveBatch()默认是循环调用insert(),每条记录都生成一个独立的PreparedStatement。5000 条记录,就意味着 5000 个PreparedStatement对象,每个对象都持有Connection和ResultSet的引用,内存占用爆炸。

解决方案:改用 JDBC 的原生addBatch():

public void batchArchive(List<AgentExecutionTrace> traces) { String sql = "INSERT INTO agent_session_archive (...) VALUES (?, ?, ?, ?)"; try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { for (AgentExecutionTrace trace : traces) { ps.setString(1, trace.getTraceId()); ps.setString(2, trace.getSessionId()); ps.setString(3, trace.getMessagesJson()); ps.setLong(4, System.currentTimeMillis()); ps.addBatch(); // 添加到批处理 } ps.executeBatch(); // 一次性执行 } }

这个方案,内存占用稳定在 10MB 以内,GC 压力极小。代价是:失去了 MyBatis-Plus 的自动 SQL 生成和实体映射,但换来的是可预测的性能。

最后一点体会:BizBuddy 的所有“取舍”,都不是为了炫技,而是为了在真实的企业交付场景里,把“不确定的 AI”装进“确定的 Java”盒子里。它不追求最前沿,但追求最可靠。当你在简历里写“熟悉 BizBuddy”,面试官问的不会是“它支持多少种 LLM”,而是“你在线上遇到过什么最难的 Agent 故障,怎么定位的?”——这才是 BizBuddy 想教会你的东西。

返回列表