
1. 体验差距到底在哪
编辑代码 → 本地运行 → 测试验证 → AI 读取结果 → 自动修复 → 再次验证 → ...
整个循环在本地完成,不需要人盯着。你甚至可以用上 autoresearch 的思想,和 AI 说:「帮我持续优化迭代这个功能,排查并修复bug,至少10轮」,AI Agent 就可以自己迭代几十轮,直到功能跑通。
但 Java 微服务项目里,这个闭环基本是断的。
这周让 AI 给一个 Agent 应用加一个新的 Tool 实现,逻辑不复杂,大概 150 行代码。AI 写完之后很自信地说”已完成”,但它没有任何办法验证自己写的东西能不能跑。因为这个项目依赖 OSS、远程沙箱、HSF 一整套云端基础设施,本地 mvn spring-boot:run 直接启动失败。
于是进入了经典循环:我把代码推到预发,等 5 分钟部署完成,手动触发一次调用,发现 NPE,截图贴回给 AI,AI 改了两行,我再推预发,再等 5 分钟……三轮下来半小时过去了,改的只是一个参数注入顺序的问题。
如果是一个本地能跑的项目,AI 自己跑一下测试就能发现这个错误,整个修复可能 30 秒就结束了。同样的模型、同样的 Prompt,差距不在 AI 能力,在如何构建 AI 友好的工程环境,即 Harness Engineering。
2. 问题的本质
这个体验差距不是偶然的,根源在微服务时代的技术架构。
通常 Java 项目都重度依赖云端微服务基础设施:HSF 做服务调用、TDDL 做数据库路由、Diamond/Switch 做配置下发、MetaQ 做消息队列。一个看起来很简单的 @Autowired,背后可能牵着一整套分布式基础设施。这些东西在云端跑得很好,但在本地全部不可用。

说白了,微服务架构天然不 AI 友好。AI Coding Agent 需要一个能在本地跑起来的环境来验证自己的输出,但微服务架构把运行时依赖全部推到了云端。项目在本地跑不起来,AI 就没办法自主验证,只能靠人去推预发、看结果、再反馈。
于是就出现了一个很常见的工作流:
本地 Vibe Coding 开发→ 推预发部署→ 人工在预发环境验证结果→ 人工把验证结果反馈给 AI→ AI 继续改→ 再次推预发 → 再次人工验证 → ...
人在每个环节都是阻塞点。跟那些本地能直接跑的项目里 AI 能自己迭代几十轮比起来,Java 微服务项目里 AI 每做一步都得等人。
最近社区关于 Harness Engineering 的讨论很多,大家聊的焦点通常在 CLAUDE.md 怎么写、lint 规则怎么配、验证脚本怎么跑。但对于 Java 微服务项目,还有一个更基础的问题:项目在本地能跑起来吗? CLAUDE.md 写得再好,AI 连代码能不能编译通过都验证不了,后面的一切都是空谈。
这篇文章聊的就是这个问题:怎么让 Java 微服务项目的本地环境变得 AI 友好,让 AI 拥有自主验证的能力。做法来自一个 Agent 应用的实际改造,算是抛砖引玉。
3. 三条改造原则
我在这次改造中总结出三条原则,后面逐个展开。
3.1 依赖倒置,接口先行
上层逻辑依赖抽象接口,不依赖具体实现。云端和本地只是接口的不同实现。
这是经典的依赖倒置(DIP),但在本地 Harness 的场景下有了实际的意义:它决定了你的系统能不能在本地跑起来。
拿我的实际改造举例。线上环境中,Agent 的文件系统(AgentFs)基于 OSS 对象存储,命令执行走远程沙箱(Sandbox),两者通过 OSS Mount 共享文件系统。这套架构在云端跑得好好的,但本地完全不可用,没有 OSS 服务,也没有远程沙箱。
改造前的依赖关系:
FilesystemService → OssStorageAdapter (具体类,直接调 OSS SDK)AgentWorkspace → SandboxCommandExecutor (具体类,调远程沙箱 API)
上层直接依赖了具体的云端实现,想在本地跑就得把整个 OSS 和 Sandbox 搬过来。不现实。
改造后,我抽了一个 StorageAdapter 接口,把上传、下载、判断存在、删除、列举这些操作统一定义好。线上的 OSS 实现加一行 implements StorageAdapter,本地新写一个 LocalStorageAdapter,用 java.nio.file 把逻辑 key 映射到本地路径。工厂类在创建文件系统时,检测到有本地参数就走本地实现,否则走 OSS 实现。
StorageAdapter (接口)├── OssStorageAdapter (线上,走 OSS SDK)└── LocalStorageAdapter (本地,走 java.nio.file)CommandExecutor (接口)├── SandboxCommandExecutor (线上,调远程沙箱 API)└── LocalCommandExecutor (本地,ProcessBuilder + bash -c)
上层所有用到文件操作和命令执行的代码完全不用改。它们只依赖接口,不关心底层是 OSS 还是本地文件系统。切换运行环境就是换一个接口实现,不用重写上层逻辑。
3.2 零侵入,Profile 隔离
本地改造不能让线上代码路径多走一行额外的代码。
这条容易被忽视。很多团队做本地化适配时,会在主流程里加 if (isLocal) 分支,线上代码路径变复杂了,出问题的风险也跟着上去。
我的做法是严格的零侵入:
Spring Profile 隔离:本地专属的 Bean 通过 @Profile("local") 装配,线上专属的通过 @Profile("!local") 守卫。两套配置编译期就彼此不可见。
("local")public class LocalRepositoryConfig {CommandExecutor localCommandExecutor() { ... }("localFsBasePath")String localFsBasePath() { ... }// 用 AtomicLong 替代 TDDL GroupSequence("sessionSequence")Sequence sessionSequence() { return new LocalSequence(); }}
同时在本地启动入口通过 @ComponentScan 的正则过滤,把线上专属的包(远程沙箱、OpenTelemetry 观测等)整个排除掉,不用在每个类上加条件注解。
@Nullable 参数注入:可选依赖标 @Nullable,不存在时 Spring 注入 null,不用写 @ConditionalOnBean。
public AgentRunner( ("localFsBasePath") String localFsBasePath, ...) {// 线上环境:localFsBasePath = null,走 OSS 路径// 本地环境:localFsBasePath = "/tmp/agentfs",走本地路径}
条件守卫:运行时通过 null 检查决定走哪条路径。localFsBasePath 不为空就走本地,ossClient 不为空就走线上,逻辑非常简单。
最终效果按侵入性分级:
线上代码路径中,这些新增代码全部被跳过:null 检查不通过、Profile 不匹配、接口多态自然走原有实现。一个检验标准:删掉所有本地相关代码后,线上行为完全不变。
3.3 工具 AI 化:CLI 优先

AI Agent 的能力边界 = 它能调用的工具的边界。GUI 对 AI 不可见,CLI 才是 AI 能用的东西。
Java 生态有大量运维和管理工具,但大多是 Web 控制台:Switch 管理台、Diamond 配置中心、HSF 服务治理台。人用着方便,但 AI Coding Agent 完全用不了。
我的做法是用 CLI 桥接企业内部系统:
# 通过 mw-cli 查询 Diamond 运行时配置值mw diamond get --unit online --data-id application.properties --group DEFAULT_GROUP# 通过 mw-cli 查询 HSF 服务地址mw hsf address --unit daily --app my-application
这些命令的输出是结构化文本,AI 直接就能解析。改造过程中,我写了一个 scripts/fetch-switch-config.sh,用 mw-cli 从预发 Diamond 拉 Switch 配置,解析成 properties 格式写到本地:
mw diamond get idealab-agent-runtime asp-switch --env staging --unit pre -o json
AI 也能直接跑这个脚本来同步最新的线上配置,不用人工登录管理台复制粘贴。
我还通过 Skill 把这些 CLI 能力注册到 AI Agent 的工具箱里,让 AI 需要的时候自己调用。
工具 AI 化的优先级:
4. 实践案例:从”推预发验证”到”本地闭环”
4.1 改造前
这个 Agent 应用是一个 AI Agent 运行时平台,核心能力之一是让 AI Agent 可以操作文件(ReadFile/WriteFile)和执行命令(Bash)。
线上架构:
-
文件操作:AgentFs → OSS 对象存储
-
命令执行:远程 Sandbox(容器沙箱)
-
文件共享:Sandbox 通过 OSS Mount 挂载 Agent 的 OSS 存储桶
本地开发时:没有 OSS、没有 Sandbox、没有 OSS Mount。Agent 的 ReadFile 写到 OSS 里的文件,Bash 在本地根本看不到。文件系统是割裂的,没法用。
4.2 改造方案
核心思路:一个接口,两套实现,Profile 条件装配。工厂类根据参数自动选择实现,上层完全无感。

改造后,本地 @Profile("local") 激活时:
-
文件系统工厂检测到本地参数,自动创建
LocalStorageAdapter,文件存到/tmp/agentfs/{sessionId}/... -
Bash 走
LocalCommandExecutor,用ProcessBuilder在本机执行bash -c命令,工作目录指向同一个本地路径 -
ReadFile/WriteFile 和 Bash 看到的是同一套文件,闭环打通了
除了文件系统和命令执行,还有一整套配套改造(详见附录):H2 替代 TDDL 数据源,AtomicLong 替代分布式 Sequence,本地 Switch 配置文件替代 Switch Center,ComponentScan 排除线上专属的 sandbox 和 observation 包。最终效果是一个完全自包含的本地运行环境,不依赖任何远程基础设施。
4.3 改造效果
改造前后的核心差异:一个完整的 bug fix 流程,改造前需要 3-4 轮人工推预发验证、总耗时 30 分钟以上;改造后 AI 在本地自主迭代,通常 2 分钟内收敛,人只需要最后 review 一下结果。
5. 配合 Harness Engineering 落地
本地 Harness 跑起来之后,还需要配合 Context Engineering 才能让 AI 真正高效地工作。具体来说,有几件事需要做:
5.1 CLAUDE.md:给 AI 一张地图
项目根目录放一份 CLAUDE.md,告诉 AI 这个项目是什么、怎么构建、怎么测试、本地环境怎么启动。不需要写得很长,100 行以内,重点是让 AI 在拿到任务后能快速定位该看哪些代码、该跑什么命令。
# 项目简介AI Agent 运行时平台,支持 ReadFile/WriteFile/Bash 等 Tool。# 本地开发- 启动:`mvn spring-boot:run -Dspring.profiles.active=local`- 测试:`mvn test`- 本地文件系统根目录:`/tmp/agentfs/`# 架构约束- 上层模块只依赖接口,不依赖具体实现类- 本地专属代码通过 @Profile("local") 隔离- 新增本地适配不得修改线上代码路径
5.2 验证脚本:让 AI 自己检查
Checklist 是给人看的,AI 需要的是可执行的验证命令。我在项目里加了一个 scripts/verify-local.sh:
# 本地 Harness 验证脚本:AI 可以直接跑这个来确认本地环境是否正常set -eecho "=== 1. 编译检查 ==="mvn compile -q -Dspring.profiles.active=localecho "=== 2. 单元测试 ==="mvn test -q 2>&1 | tail -5echo "=== 3. 本地启动检查 ==="timeout 30 mvn spring-boot:run -Dspring.profiles.active=local &PID=$!sleep 15if curl -s http://localhost:8080/actuator/health | grep -q "UP"; thenecho "✓ 本地启动成功,health check 通过"elseecho "✗ 本地启动失败"exit 1fikill $PID 2>/dev/nullecho "=== 4. 文件系统闭环检查 ==="# 验证 ReadFile/WriteFile 和 Bash 看到的是同一套文件TEST_FILE="/tmp/agentfs/test-$(date +%s)/verify.txt"mkdir -p $(dirname $TEST_FILE)echo "hello" > $TEST_FILEif [ -f "$TEST_FILE" ] && [ "$(cat $TEST_FILE)" = "hello" ]; thenecho "✓ 文件系统闭环正常"elseecho "✗ 文件系统闭环异常"exit 1firm -rf $(dirname $TEST_FILE)echo "=== 全部通过 ==="
AI 改完代码后跑一次 bash scripts/verify-local.sh,不需要人工介入就能知道本地环境是不是还能正常工作。这比 Checklist 有用得多。
6. Harness Engineering Checklist

这份清单可以用来评估任意 Java 项目的本地 AI Coding 友好度,也可以当改造的行动指引。
-
项目能否在本地通过一条命令启动?(
mvn spring-boot:run或等价命令) -
启动是否依赖外部中间件?如果是,有没有本地替代?(H2 替代 MySQL、内存队列替代 MetaQ 等)
-
外部依赖是否通过接口抽象?能否通过 Profile 切换实现?
可测试性
-
AI 能否在本地运行测试并读取结果?(
mvn test输出是否结构化) -
核心逻辑是否有单元测试?AI 改完代码后能否立即验证?
-
端到端验证是否需要人工?能否通过 API 调用 + 断言自动完成?
可观测性
-
日志是否结构化(JSON 格式)、可 grep?
-
是否集成了 CLI 化的 JVM 诊断工具?(Arthas、jstack 脚本等)
-
AI 是否能通过命令行获取运行时状态?(health check endpoint、metrics 输出等)
工具 AI 化
-
团队使用的运维工具是否有 CLI 入口?
-
配置管理(Switch/Diamond)是否可通过命令行查询?
-
是否有 MCP Server 或 Skill 将内部系统能力暴露给 AI Agent?
隔离性
-
本地改造是否通过
@Profile隔离? -
本地代码是否对线上代码路径零侵入?
-
删除所有本地相关代码后,线上行为是否完全不变?
7. 方法论总结
回过头来看这次改造,有几个可以复用的方法论。
第一,找到最小可运行子集。 不需要把所有线上能力都搬到本地,只需要找到核心链路跑通所需的最小依赖集合。这个项目的核心链路是”接收请求 → 调 LLM → 执行 Tool → 返回结果”,围绕这条链路需要的就是数据库、文件系统、命令执行三个基础设施。其他的比如监控、链路追踪、服务发现,本地不需要,排除就好。
第二,替代而非模拟。 H2 不是在”模拟” MySQL,它就是一个真实的关系型数据库,跑的是真实的 SQL。LocalCommandExecutor 不是在”模拟”沙箱,它就是在执行真实的 bash 命令。替代方案要能真实运行,不能只是返回 mock 数据。这样 AI 在本地发现的问题,线上大概率也会有。
第三,脚本化一切人工操作。 但凡需要人登录某个管理台、复制某个配置、点击某个按钮才能完成的操作,都应该有一个对应的脚本。fetch-switch-config.sh 替代了”登录 Switch 管理台 → 找到应用 → 复制配置”的人工流程。start-local.sh 替代了”先编译、再配置环境变量、再选对 main 类启动”的多步操作。脚本就是 AI 的手,没有脚本 AI 就是残废的。
第四,分层隔离,逐层验证。 不要想着一步到位把所有东西都改完再测。先让项目能编译,再让它能启动,再让核心接口能调通,再跑端到端测试。每一层都有对应的验证手段:mvn compile、/checkpreload.htm、API 冒烟测试、Playwright E2E。AI 也可以按这个顺序逐层排查问题。
第五,让 AI 成为改造的参与者。 这次改造本身就是和 AI 一起完成的。我先手动搞定了 H2 替换和核心接口抽象,确保项目能在本地启动。之后 StorageAdapter 的实现、LocalCommandExecutor 的异常处理、冒烟测试脚本,都是 AI 在本地闭环里自己迭代出来的。一旦 AI 能跑测试、能看到报错,它的效率就上来了。这形成了一个正向循环:每完成一步改造,AI 能做的事情就多一点,下一步改造就更快。
8. 后续方向
本地环境还可以再往前走一步,把 JVM 诊断能力也工具化。比如应用响应超时时自动跑 jstack 把结果输出到 AI 能读的地方,或者通过 Skill 封装 Arthas 的 watch、trace、tt 命令,让 AI 实时观测方法调用。异常信息如果能以 JSON 格式输出,AI 直接就能解析定位到具体代码行。
理想的状态是 AI 不光能跑测试,还能理解测试为什么失败:
AI 修改代码 → mvn test → 解析 Surefire 报告 → 定位失败用例→ 读取相关源码 → 分析原因 → 修复 → 再次测试 → ...
这要求测试输出是结构化的、错误信息是可解析的、日志是可追溯的。目标就是让 AI 在本地能完全自主地跑完”发现问题→定位原因→修复代码→验证修复”这整个循环,跟人类开发者拥有一样的本地开发能力。
9. 附录:本项目本地化改造清单
下面列举这个 Agent 应用为了本地化部署做的所有改动,可以作为其他 Java 项目本地化改造的参考。
9.0 这个应用依赖了什么
改造之前先盘一下,这个应用到底挂在哪些东西上面:
-
数据库:TDDL 连 MySQL 集群,本地没有 TDDL 数据源,Spring 启动直接报 Bean 创建失败
-
分布式 ID:TDDL GroupSequence 靠数据库表做号段分配,TDDL 起不来它也起不来
-
对象存储:文件系统基于 OSS,本地没有 OSS 服务,Agent 的 ReadFile/WriteFile 全废
-
命令执行:走远程沙箱,本地没有沙箱服务,Bash Tool 直接报错
-
配置中心:API Key、超时时间、功能开关这些运行时配置走 Switch Center 动态下发,本地读不到
-
中间件:EagleEye、HSF、Sunfire、Pandora 这些的 AutoConfiguration 启动时会尝试初始化,本地没有这些基础设施,启动直接挂
-
监控:OpenTelemetry 埋点、metrics 导出,本地不需要但不排除也会报错
叠在一起的效果就是 mvn spring-boot:run 一跑一屏报错。下面逐个说怎么解决。
9.1 数据源:H2 替代 TDDL
线上用 TDDL 连 MySQL 集群,本地用 H2 文件数据库,零外部依赖。
spring.datasource.url=jdbc:h2:file:./data/local-db;AUTO_SERVER=TRUE;MODE=MySQLspring.datasource.driver-class-name=org.h2.Driverspring.datasource.username=saspring.datasource.password=
配合 schema-h2.sql 自动建表。表结构和线上 MySQL 保持一致,字段名、索引、约束都对齐。Repository 层用的是同一套 JDBC 实现,不需要为本地单独写一套。
spring.sql.init.mode=always 让每次启动自动执行建表脚本,H2 的 MODE=MySQL 兼容 MySQL 语法,基本不用改 SQL。
9.2 分布式 ID:AtomicLong 替代 GroupSequence
线上用 TDDL GroupSequence 生成全局唯一 ID,本地用 AtomicLong 包一层就够了:
public class LocalSequence implements Sequence {private final AtomicLong counter = new AtomicLong(System.currentTimeMillis() % 1_000_000);public long nextValue() { return counter.incrementAndGet(); }}
本地配置类里注册了 session、message、checkpoint 等多个 Sequence Bean,覆盖线上所有需要分布式 ID 的地方。用时间戳取模做种子,本地够用了,不会冲突。
9.3 Switch/Diamond 配置:脚本拉取到本地
线上通过 Switch Center 动态下发配置,本地通过 switch-config-local.properties 文件模拟。
scripts/fetch-switch-config.sh 做的事情:
-
调
mw diamond get从预发环境拉 Switch 配置(JSON 格式) -
用 Python 脚本解析 JSON,提取
SwitchConfig.*前缀的字段 -
写入
switch-config-local.properties,格式类似switch.modelApiKey=xxx -
缺失的字段补上默认值(
defaultTimeoutMs=60000、maxReactIterations=10等)
本地配置类的 @PostConstruct 方法会读这个文件,把值设到 Switch 配置的静态字段上。API Key 的优先级是:properties 文件 > 环境变量 > application 配置 > 空值。
这样做的好处是 AI 随时可以跑 bash scripts/fetch-switch-config.sh 来同步最新配置,不需要人工登管理台。
9.4 包排除:ComponentScan 正则过滤
本地启动入口用 @ComponentScan 的 excludeFilters 把线上专属的包整个排掉:远程沙箱包、OpenTelemetry 观测包,本地不需要的整包排除。
再加上 application-local.properties 里的 spring.autoconfigure.exclude,把 EagleEye、HSF、TDDL、Sunfire 等中间件的自动配置全部跳过。
排除的原则很简单:如果一个包的所有类都是线上专属的,用 ComponentScan 排除;如果是某个自动配置类,用 exclude 排除。不需要在每个类上加 @Profile 或 @ConditionalOnProperty。
9.5 一键启动:start-local.sh
# 一条命令启动本地环境bash scripts/start-local.sh
这个脚本做了三件事:
-
检查
switch-config-local.properties是否存在,不存在就自动跑fetch-switch-config.sh -
mvn compile -q编译项目 -
拼接 classpath,直接
java -cp启动LocalApplication,监听 7001 端口
启动后 http://localhost:7001/checkpreload.htm 返回 “success” 就说明环境正常。AI 在 CLAUDE.md 里看到这条命令,改完代码直接跑就行。
9.6 端到端冒烟测试
tests/smoke/local-chat-smoke.mjs 是一个 Node.js 写的冒烟测试,覆盖三个层面:
-
Health Check:
GET /checkpreload.htm返回 “success”,H2 控制台 API 能正常查表 -
API 验证:创建 Session → SSE 流式调用 → 验证
message和MODEL_STOP事件 → 查询消息历史 → 确认数据落库 -
UI E2E:Playwright 打开页面,创建会话,发送消息,等待 AI 回复,截图保存到
/tmp
AI 改完代码跑一次 node tests/smoke/local-chat-smoke.mjs,几秒钟就能知道核心链路有没有挂。比人工在预发点点点快得多。
9.7 改造全景图
把上面这些改动汇总一下:
