我用 SubAgent 做了一个 AI 自动修复闭环:流式修代码、自动构建、失败重试

最近用 AI 零代码项目生成前端页面有时候会出现问题,但是直接把原始报错信息返回给前端,再让用户手动点击按钮才能开始修复,用户体验不是很好,那么如何优化呢?我想到了 SubAgent 来进行修复!

如果把这类修复工作一直交给主 Agent 来做,会带来两个明显问题:

  1. 修复过程会不断污染主 Agent 的上下文。
  2. 错误日志、修复尝试和中间结果会迅速挤占上下文窗口,影响主 Agent 后续继续处理其他任务。
  3. 并且越接近上下文窗口的上线,AI 的注意力就会"涣散"生成的效果也就越差。

源码

欢迎各位大佬 Star ⭐

源码地址:github.com/lieeew/leik...

整体流程

完整流程如下:

  1. 用户点击"修复错误"。
  2. 后端启动 SubAgent 的 SSE 修复流。
  3. 查询当前激活版本号,定位当前版本目录。
  4. 读取当前版本 metadata.json 中的 errorLog
  5. SubAgent 基于 errorLog 流式修复代码,前端实时展示修复内容。
  6. AI 输出结束后,后端执行 build 和构建产物校验。
  7. 后端更新当前版本的 metadata.json 和数据库状态。
  8. 前端根据 SSE 展示本轮构建结果。
  9. 如果构建失败,则进入下一轮修复,最多重试 3 次。
  10. 如果最终修复成功,前端刷新版本列表和预览。
  11. 修复成功后,前端把 SubAgent[Fix Summary] 回传给主 Agent,让主 Agent 感知这次修复结果。

为什么不用主 Agent 直接修

主 Agent 负责的是整条业务链路,它需要理解页面、任务上下文、用户目标和历史操作。如果让它直接参与一轮又一轮的报错修复,短时间内确实能解决问题,但长期来看成本很高:

  1. 大量错误日志和修复过程会污染主上下文。
  2. 主 Agent 的注意力会被局部报错吸走,后续做别的任务时上下文质量下降。
  3. 当上下文窗口逐渐逼近上限时,模型输出稳定性也会明显变差。

因此更合适的做法是把"局部、重复、面向报错"的工作隔离给 SubAgent,让主 Agent 只消费最终结果,而不是参与整个修复过程。

为什么在 Reactor 里必须切线程

这里有一个很容易踩的坑:我们使用的是 Reactor 的非阻塞线程模型,并且整个修复过程要通过 SSE 持续向前端推送状态。

在这种模式下,处理请求和推进事件循环的线程数量是有限的。如果把 build、文件读写、等待外部进程结束这类阻塞操作直接放到 Reactor 线程里执行,线程就会被长期占住,其他请求和 SSE 推送也会受到影响,系统吞吐量和响应性都会明显下降,用户体验直接爆炸。

Tomcat 传统上更接近"一请求一线程"模型。某个请求阻塞了,主要影响的是这个请求本身,只要线程池里还有空闲线程,其他请求通常还能继续跑。但 Reactor 是"少量线程处理大量连接"的事件驱动模型,核心线程的职责是分发和推进事件,一旦把阻塞逻辑塞进去,影响的就不只是当前请求,而是整个事件循环。

所以这里所有阻塞操作都要切到 boundedElastic 线程池执行:

java 复制代码
Mono.fromCallable(this::doBlockingWork)
    .subscribeOn(Schedulers.boundedElastic());

这个原则在本文里贯穿始终。像读取 metadata.json、执行 build、更新状态这类操作,都会放到 boundedElastic 上处理。

关键实现

1. 对外暴露一个 SSE 修复接口

前端点击"修复错误"后,后端会启动一个 SSE 流,把修复中的阶段信息、AI 输出和构建结果持续推给前端。

java 复制代码
@RestController
@RequestMapping("/app")
public class AppController {

    @GetMapping(value = "/sub-agent/fix", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<ServerSentEvent<String>> fixAppWithSubAgent(
            @RequestParam(name = "appId") Long appId) {
        ThrowUtils.throwIf(appId == null, ErrorCode.PARAMS_ERROR);
        UserVO user = userService.getUserLogin();
        return subAgentService.executeFixLoop(appId.toString(), user);
    }
}

对应的服务接口非常简单:

java 复制代码
public interface SubAgentService {

    Flux<ServerSentEvent<String>> executeFixLoop(String appId, UserVO user);
}

2. 给 SubAgent 单独的 ChatClient 和会话隔离

为了避免修复流程污染主 Agent 的聊天记录,我单独给 SubAgent 配了一个 ChatClient,并使用独立的内存 ChatMemory。这里有三个关键点:

  1. SubAgent 的聊天记录不是核心资产,使用内存存储即可。
  2. 每一次修复任务都要生成独立的 conversationId,不能复用默认值,也不能直接复用 appId
  3. 整个修复循环结束后,再统一清理这次会话的聊天记录。

如果不显式指定 conversationId,默认值很容易导致不同修复任务之间出现上下文污染;如果直接用 appId,连续两次修复同一个应用时,历史聊天记录又会串起来,导致上下文重复累积。

因此更合理的做法是为每次修复生成一个唯一会话 ID,例如:

java 复制代码
private String generateSubAgentConversationId(String appId) {
    return "subfix:" + appId + ":" +
            UuidV7Generator.bytesToUuid(UuidV7Generator.generate());
}

然后把这个 conversationId 显式传给 ChatClient,这里的参数就指定了 CONVERSATION_ID

java 复制代码
advisorSpec.param(CONVERSATION_ID, conversationId)

完整的代码如下:

java 复制代码
.advisors(advisorSpec -> {
    advisorSpec.param(GEN_APP_INFO, ChatContext.of(appId, userId));
    advisorSpec.param(CONVERSATION_ID, conversationId);
})
.toolContext(Map.of(GEN_APP_INFO, ToolsContext.of(appId, userId)))

ChatClient 的配置本身并不复杂,重点是把它和主 Agent 的上下文彻底隔离开:

java 复制代码
@Configuration
public class ChatClientConfig {

    @Bean("subAgentChatMemoryRepository")
    public ChatMemoryRepository subAgentChatMemoryRepository() {
        return new InMemoryChatMemoryRepository();
    }

    @Bean("fixChatClient")
    public ChatClient fixChatClient(
            ChatModel primaryChatModel,
            ExecuteToolAdvisor executeToolAdvisor,
            SystemMessageFirstAdvisor systemMessageFirstAdvisor,
            FileTools fileTools,
            TodolistTools todolistTools,
            ToolAdvisor toolAdvisor,
            @Qualifier("subAgentChatMemoryRepository")
            ChatMemoryRepository subAgentChatMemoryRepository) {

        TodolistCacheCleanupAdvisor todolistCacheCleanupAdvisor =
                new TodolistCacheCleanupAdvisor();

        return ChatClient.builder(primaryChatModel)
                .defaultAdvisors(
                        executeToolAdvisor,
                        systemMessageFirstAdvisor,
                        toolAdvisor,
                        todolistCacheCleanupAdvisor,
                        MessageChatMemoryAdvisor.builder(
                                MessageWindowChatMemory.builder()
                                        .chatMemoryRepository(subAgentChatMemoryRepository)
                                        .maxMessages(100)
                                        .build()
                        ).build()
                )
                .defaultTools(fileTools, todolistTools)
                .build();
    }
}

3. 用 Flux.concat() 串起"修复 -> 构建 -> 决策下一轮"

这里的执行顺序是严格的:

  1. 先让 AI 修代码。
  2. 再执行 build 和校验。
  3. 根据结果决定是结束,还是进入下一轮修复。

所以这里必须使用 Flux.concat(),而不是 Flux.merge()

Flux.concat() 会按顺序订阅上游,前一个流只有在发送 onComplete 之后,后一个流才会开始执行;这正好符合"修复完成后再构建"的要求。

相反,Flux.merge() 会并发订阅多个流,数据会按到达顺序交错发给下游。它适合并行汇聚多个事件源,但不适合这种必须按阶段推进的修复链路。

核心逻辑如下:

java 复制代码
private Flux<ServerSentEvent<String>> doFixLoopReactive(FixLoopContext context, int attempt) {
    if (attempt > MAX_ATTEMPTS) {
        return Flux.just(doneEvent(
                new DoneEventPayload(false, MAX_ATTEMPTS, "", context.aiContentAsString())));
    }

    return Flux.concat(
            streamAiFixReactive(context, attempt),
            continueAfterAi(context, attempt)
    ).onErrorResume(e -> handleAiFailure(context, attempt, e));
}

其中,AI 修复阶段会先读取当前 errorLog,然后把模型输出按 SSE 方式流式推给前端:

java 复制代码
private Flux<ServerSentEvent<String>> streamAiFixReactive(FixLoopContext context, int attempt) {
    return Mono.fromCallable(() -> getErrorLogs(context))
            .subscribeOn(Schedulers.boundedElastic())
            .flatMapMany(errorLog -> {
                GenAppDto dto = new GenAppDto(
                        errorLog, context.appId, context.user, context.conversationId);
                return Flux.concat(
                        Flux.just(phaseEvent(FixPhaseEnum.FIXING, attempt)),
                        aiChatClient.fixCode(dto)
                                .timeout(Duration.ofMinutes(AI_TIMEOUT_MINUTES))
                                .doOnNext(context.aiContent::append)
                                .map(this::dataEvent)
                );
            });
}

4. 构建失败就继续下一轮,最多 3 次

AI 输出结束后,会立即进入 build 和产物校验阶段。这里同样要把阻塞操作放到 boundedElastic 线程池里执行:

java 复制代码
private Flux<ServerSentEvent<String>> continueAfterAi(FixLoopContext context, int attempt) {
    return Mono.fromCallable(() -> {
                BuildValidationResult buildResult = buildAndValidate(context.appId);
                updateMetadataFile(context, buildResult);
                syncVersionStatus(context, buildResult);
                return buildResult;
            })
            .subscribeOn(Schedulers.boundedElastic())
            .flatMapMany(buildResult -> afterBuild(context, attempt, buildResult));
}

如果构建成功,直接发送 done 事件; 如果失败但还没到上限,就继续下一轮; 如果达到最大次数仍然失败,则返回最终失败结果:

java 复制代码
private Flux<ServerSentEvent<String>> afterBuild(
        FixLoopContext context, int attempt, BuildValidationResult buildResult) {

    Flux<ServerSentEvent<String>> buildEvents = buildEvents(buildResult, attempt);

    if (buildResult.success()) {
        return Flux.concat(buildEvents, Flux.just(doneEvent(successPayload(context, attempt))));
    }

    if (attempt >= MAX_ATTEMPTS) {
        return Flux.concat(buildEvents, Flux.just(doneEvent(failurePayload(context, attempt))));
    }

    return Flux.concat(buildEvents, doFixLoopReactive(context, attempt + 1));
}

5. 修复循环结束后统一清理聊天记录

这里还有一个细节值得单独说一下:聊天记录不是在每一轮重试后立刻清,而是在整个修复循环彻底结束后统一清理。

原因如下: 1、如果每次重试都把上下文清掉,下一轮修复就拿不到上一轮已经产生的修复信息。 2、如果一直不清理,长时间积累又会增加内存压力,甚至带来 OOM 风险。

所以更平衡的做法是:在一次完整修复任务结束后,通过 doFinally 做统一清理。

java 复制代码
@Override
public Flux<ServerSentEvent<String>> executeFixLoop(String appId, UserVO user) {
    Long appIdLong = Long.parseLong(appId);

    return Mono.fromCallable(() -> appVersionService.getCurrentVersionNum(appIdLong))
            .subscribeOn(Schedulers.boundedElastic())
            .defaultIfEmpty(0)
            .flatMapMany(currentVersionNum -> {
                if (currentVersionNum == null || currentVersionNum <= 0) {
                    return missingVersionEvents();
                }

                FixLoopContext context = new FixLoopContext(
                        appId,
                        currentVersionNum,
                        user,
                        generateSubAgentConversationId(appId)
                );

                return doFixLoopReactive(context, 1)
                        .doFinally(signalType -> cleanupConversationMemory(context));
            });
}

清理逻辑本身很直接:

java 复制代码
private void cleanupConversationMemory(FixLoopContext context) {
    try {
        subAgentChatMemoryRepository.deleteByConversationId(context.conversationId);
        log.info("[SubAgent] Cleared chat memory, appId: {}, conversationId: {}",
                context.appId, context.conversationId);
    } catch (Exception e) {
        log.warn("[SubAgent] Failed to clear chat memory, appId: {}, conversationId: {}",
                context.appId, context.conversationId, e);
    }
}

前端效果

前端这部分主要做了三件事:

  1. 在用户触发修复后,及时展示当前状态和错误提示。
  2. SubAgent 的流式输出实时渲染出来,方便观察修复过程。
  3. 在修复成功后,把 [Fix Summary] 回传给主 Agent,完成主从 Agent 之间的信息闭环。

如果出现错误,右上角会展示对应提示:

修复中的流式输出会展示在对话区域:

修复完成后,再把摘要通知给主 Agent:

总结

至此 SubAgent 的实现告一段落,大概有以下几点:

  1. SubAgent 负责局部修复,避免污染主 Agent 上下文。
  2. SSE 让修复过程可观察,前端能实时感知阶段变化。
  3. build 和产物校验把"修没修好"从主观判断变成了可验证结果。
  4. 自动重试让整个链路具备了基础的自愈能力。

下一期准备使用使用 Docker 隔离 AI 生成的代码,让 AI 生成的代码先在 Docker 里面跑 build => 成功生成 dist 文件 => 复制出来 => 前端进行展示

相关推荐
考虑考虑6 小时前
数据库中的EXISTS
运维·数据库·后端
Wang's Blog6 小时前
Java框架快速入门: Spring Security+OAuth2之数据库和实体类的RBAC改造
java·数据库·spring
郑州光合科技余经理7 小时前
国际版外卖系统:税率字段怎么和订单主流程解耦
android·java·开发语言·前端·后端·php·ai编程
毅炼7 小时前
Rover-Suite 开源发布:轻量服务注册中心与网关一体化方案
java·后端·系统架构·gateway
小虎AI生活9 小时前
微信 AI 社交灰度测试:企业 AI 落地真正的门槛在知识库治理
ai编程
猫吻鱼9 小时前
【AI 01】【Spring AI 基础使用】
java·spring·spring ai
蚕豆糯米饭9 小时前
Github Copilot 研发效能提升实战指南
ai·github·copilot·ai编程
Ray Wang10 小时前
Agent学习
ai编程
程序员老刘12 小时前
Android Studio Quail 4发布,看日志我以为谷歌放弃Flutter了
flutter·android studio·ai编程
苏三说技术12 小时前
为什么越来越多人用OpenWiki?
后端