前两集讲了灰度路由怎么决定命中哪个版本的 Agent,注册表怎么加载工厂。但工厂拿到版本号之后做什么?factory.createAgent(rc) 这一行(AguiChatController 第 159 行)背后,是从 DB 配置到一个可运行 HarnessAgent 的完整装配过程。
这一篇钻进 BaseFinanceAgentFactory------平台唯一的工厂实现,379 行代码,一个 createAgent() 方法里挂了 14 个 Builder 开关,把 DB 里的 sys_prompt、model_params、工具、技能、HITL 规则、子代理全部翻译成框架认识的对象。
不可变快照:volatile 引用整体替换
先看装配的基础设施。工厂持有一个 AgentConfigSnapshot 引用:
java
private volatile AgentConfigSnapshot configSnapshot;
这个快照是不可变对象 ------AgentConfigSnapshot 全字段 final,构造后没有 setter,每个字段在 Builder 的 build() 方法里一次定型。buildTimestamp 记录构建时间(第 46 行 System.currentTimeMillis()),可以用来排查「这个快照是哪次刷新时建的」。
为什么用不可变对象?因为热更新靠的是 volatile 引用的整体替换,不是字段级修改。refreshFromDb() 方法(第 361-373 行)的整个逻辑就是:
java
public final void refreshFromDb() {
Timer.Sample sample = Timer.start();
try {
AgentConfigSnapshot newSnap = snapshotLoader.get();
if (newSnap == null) {
throw new IllegalStateException("snapshotLoader returned null for " + simpleName());
}
validate(newSnap);
this.configSnapshot = newSnap;
} finally {
sample.stop(agentMetrics.snapshotLoadTimer());
}
}
三步:调 snapshotLoader.get() 装载新快照(DRAFT 读 DB 实时配置,PUBLISHED 读快照 JSON,第 04 集讲过)、validate() 校验 agentCode 一致性、configSnapshot = newSnap 整体替换 volatile 引用。
替换瞬间,所有新请求看到的都是新快照。 正在处理的旧请求继续用旧快照(因为 createAgent() 第 125 行做了一次 volatile 读拿到引用后,全程用这个 snap 局部变量访问字段,不再碰 volatile 字段)。这就是注释里说的「只做一次 volatile 读,全程用 snap」------无锁的热更新,正在跑的请求不受影响,新请求立即生效。
装配入口:createAgent 的三行准备
createAgent(RuntimeContext ctx) 第 122-193 行。进入 Builder 链之前,三行准备:
java
AgentConfigSnapshot snap = this.configSnapshot; // volatile 读
Model model = llmRegistry.getModel(snap.getAgentCode());
Path workspaceRoot = resolveWorkspaceRoot(snap);
int maxIters = snap.getModelParams() != null && snap.getModelParams().maxIters() != null
? snap.getModelParams().maxIters() : 8;
第一行 volatile 读拿到快照引用,之后全程用 snap。
第二行从 LlmConfigRegistry 按agentCode 解析模型。注意这里不持有 Model 实例------V2.4-D 的设计决策(类头注释第 44-45 行):每次 createAgent 经 llmRegistry.getModel() 路由,cache hit 直接返,miss 触发 build。这让模型可以在运行时切换(熔断后切备用模型),不受工厂快照的快照固化限制。第 06 集专讲 LLM 路由和熔断。
第三行解析工作区根目录,resolveWorkspaceRoot() 方法(第 352-358 行):
java
private Path resolveWorkspaceRoot(AgentConfigSnapshot snap) {
String configured = Optional.ofNullable(snap.getWorkspaceDir())
.map(String::trim)
.filter(s -> !s.isEmpty())
.orElse(".agentscope/workspace/" + snap.getAgentCode());
return Paths.get(configured);
}
DB 配了 workspace_dir 就用配置值,没配就走默认 .agentscope/workspace/<agentCode>。这个目录是 HarnessAgent 的工作区------人格文件 AGENTS.md、记忆文件 MEMORY.md、子代理定义、技能文件全在这里。第 14 集讲 Skills 时会钻进去。
十四个 Builder 开关
进入正题。第 131-189 行是一条 HarnessAgent.builder() 链,14 个开关逐个走读。
开关 1-2:名字和提示词
java
.name(snap.getAgentName() != null ? snap.getAgentName() : snap.getAgentCode())
.sysPrompt(snap.getSysPrompt() != null ? snap.getSysPrompt() : "")
Agent 的显示名称和系统提示词。注意 sysPrompt 的优先级------它低于工作区的 AGENTS.md 文件,后者会追加覆盖这个提示词。所以 DB 配的 sys_prompt 是「基础人设」,工作区 AGENTS.md 是「场景特化」,两者叠加。
开关 3:模型
java
.model(model)
上面准备阶段拿到的 Model 对象。框架的 Model 是个 SPI 接口,平台通过 SdkChatModelBuilder 把各家模型厂商的实现接进来------第 06 集详讲。
开关 4:中间件
java
.middlewares(middlewares)
中间件列表,Spring 注入的所有 MiddlewareBase 实现。框架在 Agent 生命周期的关键位置拦截扩展行为------压缩、卸载、技能、Plan 都以中间件形式实现。第 10 集专讲上下文工程的两个中间件(CompactionMiddleware 和 ToolResultEvictionMiddleware)。
开关 5:状态持久化 SPI
java
.stateStore(stateStore)
这是第 01 集说过的 SPI 插件点。框架定义 AgentStateStore 接口,平台提供 DbAgentStateStore 实现(落 MySQL)。stateStore 为 null 时框架用默认的 InMemoryAgentStateStore------这就是为什么注释说「null keeps the agent in in-memory mode」(第 73-78 行)。生产环境这个值不为 null,所以 Agent 每轮状态都落库,用户刷新页面对话还在。第 09 集专讲。
开关 6:最大迭代轮数
java
.maxIters(maxIters)
Agent 一轮任务里最多允许执行多少次「思考-工具调用」循环。来自 model_params.maxIters,没配默认 8。防 Agent 死循环------第 03 集讲过 ExceedMaxItersEvent 会被映射成 ERROR 事件推给前端。
开关 7:工作区
java
.workspace(workspaceRoot)
上面解析的工作区路径。HarnessAgent 会在这个目录下管理 AGENTS.md、MEMORY.md、memory/、skills/、subagents/ 等文件------这是框架的文件系统驱动设计,不是平台自建的。
开关 8:对话压缩
java
.compaction(CompactionConfig.builder()
.triggerMessages(50)
.keepMessages(20)
.build())
上下文快溢出时自动精简历史消息。配的是「50 条消息触发压缩,保留最近 20 条」------压缩后旧消息被 __compaction_summary__ 替代渲染为 system 消息。第 10 集专讲压缩与卸载的参数和机制。
开关 9:大工具结果卸载
java
.toolResultEviction(buildToolResultEvictionConfig())
工具返回超大文本时落盘保存,上下文只放占位符。默认阈值 80K 字符。buildToolResultEvictionConfig() 方法(第 218-225 行)配的是覆盖排除集:
java
ToolResultEvictionConfig.builder()
.excludedToolNames(java.util.Set.of(
"read_file", // 卸载文件内容会触发 read_file 重读循环;自带 offset/limit 分页
"write_file", "edit_file", // 仅返回极小成功消息
"memory_search", "memory_get", "session_search")) // 小结果/自分页
.build();
这里有个真实事故值得记住(第 196-215 行的注释完整记录了)。框架默认的 ToolResultEvictionConfig.defaults() 把 list_files、grep_files、glob_files 列入排除集------Javadoc 标注它们 "self-limiting outputs"(输出有上限),卸载反而有害。但实测证伪:这三个工具对大仓库返回全量匹配行、无任何上限 (FilesystemTool 用 Collectors.joining("\n") 拼接,无截断)。单轮调用就能把上下文撑到 228 万 token 触发模型 400。
所以平台覆盖了排除集:移除 list_files/grep_files/glob_files,让 80K 阈值对它们生效 。仅保留确实有上限或卸载有害的工具(read_file 卸载后会触发重读循环、write_file/edit_file 只返回极小消息、记忆类工具自分页)。这个事故第 12 集插页会完整复盘。
开关 10:长期记忆
java
.memory(MemoryConfig.defaults())
开启框架的双层长期记忆------自动把对话内容沉淀到 MEMORY.md,记忆检索注入上下文。用 defaults() 意味着默认配置,不做特化。第 11 集专讲记忆系统与平台的 MemoryService 的边界。
开关 11:技能仓库
java
.skillRepositories(skillRepositories)
AgentSkillRepository 是框架的 SPI,平台提供 MySQL 实现(MysqlSkillRepositoryConfig)。告诉 Agent 去哪里读 SKILL.md 技能文件。第 14 集专讲三源技能加载。
开关 12:技能绑定过滤
java
if (boundSkillNames != null && !boundSkillNames.isEmpty()) {
b.skillFilter(SkillFilter.only(boundSkillNames.toArray(new String[0])));
}
V2.2 收尾 #12 的设计:快照装了绑定的技能名列表,builder 用 SkillFilter.only() 限定只有这些技能生效。null/空 = 不过滤 = 全量透传。这个绑定是 DB 驱动的------管理页配了哪些技能,这里就过滤出哪些。
开关 13-14:Plan 模式和任务列表
java
if (snap.getModelParams() != null) {
if (Boolean.TRUE.equals(snap.getModelParams().enablePlan())) {
b.enablePlanMode(true);
}
if (Boolean.TRUE.equals(snap.getModelParams().enableTaskList())) {
b.enableTaskList(true);
}
}
两个 AgentScope 原生开关,来自 model_params JSON 里的 enablePlan 和 enableTaskList。enablePlan 开启 plan_enter/plan_write/plan_exit 工具 + 审批门控(plan_exit 走 ASK 等人工确认);enableTaskList 开启 todo_write 任务跟踪。第 21 集讲 Plan 模式时详讲。
后置开关 15-17:工具、HITL、子代理
Builder 链跑完后还有三个条件性开关:
java
if (snap.getToolkit() != null) {
b.toolkit(snap.getToolkit());
}
PermissionContextState permCtx = resolvePermissionContext(snap, ctx);
if (permCtx != null) {
b.permissionContext(permCtx);
}
List<SubagentDeclaration> subDecls = buildSubagentDeclarations(snap);
if (!subDecls.isEmpty()) {
b.subagents(subDecls);
}
return b.build();
工具 (第 174-177 行):Toolkit 对象在快照里已经构建好(draftLoader/publishedLoader 时组装),包含这个 Agent 的所有 Java 工具和 MCP 工具。null = 框架默认空工具集。第 07 集专讲工具体系。
HITL 权限上下文(第 178-183 行):这个最复杂,下面单独讲。
子代理 (第 185-188 行):buildSubagentDeclarations() 从快照构建远程子代理声明列表。第 15 集讲多 Agent 协作时详讲,这里只要知道它走编程式注册 SubagentDeclaration.builder().url(),安全默认 RemoteAskPolicy.DENY。
per-request 权限重建
resolvePermissionContext() 方法(第 240-258 行)是装配里唯一一个 per-request(每次请求重建)的逻辑,其余都是快照固化值。
java
PermissionContextState resolvePermissionContext(AgentConfigSnapshot snap, RuntimeContext ctx) {
PermissionContextState base = snap.getPermissionContext();
if (rolePermissionResolver == null || ctx == null) {
return base;
}
List<String> roleCodes = ctx.get("roleCodes");
if (roleCodes == null || roleCodes.isEmpty()) {
return base;
}
Set<String> toolNames = snap.getToolkit() == null ? Set.of() : snap.getToolkit().getToolNames();
Map<String, List<AcToolRolePermission>> overrides =
rolePermissionResolver.resolveOverrides(roleCodes, snap.getAgentCode(), toolNames);
if (overrides.isEmpty()) {
return base;
}
log.info("[BaseFinanceAgentFactory] role permission overrides applied: agent={}, roles={}, tools={}",
snap.getAgentCode(), roleCodes, overrides.keySet());
return rolePermissionResolver.rebuildPermissionContext(base, overrides);
}
四层逻辑:
- 快照固化的 base :启动时从 DB 加载的 HITL 规则(全局规则 + Agent 级规则),已经构建成
PermissionContextState固化在快照里。 - 无角色就返回 base :
ctx里没有roleCodes(评测 / 内部调用 / auth disabled),直接用快照值,行为零变化。 - 有角色但无覆盖也返回 base :有 roleCodes 但这个 Agent 没配角色级规则行(
overrides为空),避免无谓重建。 - 有角色规则就重建 :
ToolRolePermissionResolver.resolveOverrides()查这个 Agent + 这些角色的工具权限覆盖,rebuildPermissionContext()把角色规则叠加到 base 上------被覆盖工具排除全局规则并注入角色规则,但 mode/workingDirectories/其余规则保留。
这个设计解决了什么问题?同一个 Agent,不同角色看到不同的工具审批策略。比如 execute_sql 工具,管理员角色配了 BYPASS(直接执行不审批),普通角色配了 ASK(每次执行要审批)。快照固化的全局规则是「ASK」,但管理员的请求会把 execute_sql 覆盖成 BYPASS------每次请求重建权限上下文,而不是启动时固化。第 13 集专讲 HITL 五级 scope 和审批机制。
快照里有什么
回头看 AgentConfigSnapshot,13 个字段,分三类:
DB 直接映射 :agentCode、agentName、sysPrompt、modelParams、workspaceDir------从 ac_agent_config 主行直接读出。
运行时构建 :toolkit(工具集,包含 Java 工具和 MCP 工具,在 loader 里组装)、skillBox(技能盒子)、permissionContext(HITL 权限上下文,hitlConfigService.buildPermissionContext() 构建)、boundSkillNames(绑定的技能名列表)、remoteSubagents(远程子代理声明)------这些不是 DB 直接字段,是 loader 把多张子表的数据组装出来的。
元信息 :variant(DRAFT/PUBLISHED)、publishVersion(发布版本号)、buildTimestamp(构建时间戳)------标识这个快照的身份和来源。
ModelParams 是个 record(第 10-18 行),7 个字段从 ac_agent_config.model_params JSON 解析:temperature、topP、maxTokens、maxIters、enablePlan、enableTaskList、modelName。fromJson() 方法(第 25-38 行)做 JSON 解析,每个字段都有默认值------DB 里没配 temperature 就用 0.7,没配 maxIters 就用 8。
validate:启动期强校验
refreshFromDb() 调装载器拿到新快照后,先走 validate()(第 331-343 行):
java
protected void validate(AgentConfigSnapshot snap) {
String bareCode = bareCode();
if (!snap.getAgentCode().equals(bareCode)) {
throw new IllegalStateException(
"agentCode mismatch: factory=" + bareCode
+ " snap=" + snap.getAgentCode());
}
if (snap.getAgentCode().contains(AgentVariant.PUBLISHED_SUFFIX)) {
throw new IllegalStateException(
"agentCode cannot contain " + AgentVariant.PUBLISHED_SUFFIX
+ " suffix: " + snap.getAgentCode());
}
}
两个校验:快照的 agentCode 必须和工厂的裸 agentCode 一致(防止 data_analyst_published 的工厂加载到 other_agent 的配置),agentCode 不能包含 _published 后缀(保留给注册 key 用)。bareCode() 用 AgentVariant.parse() 从注册 key(可能带后缀)反解出裸 agentCode。
这个校验在启动时和每次 refreshFromDb() 时都跑------启动时跑是防线(配置错了直接崩,不带着错误配置上线),运行时刷新也跑是防止 DB 被改坏后热更新进脏配置。
不要加 @Component
类头注释第 52-58 行有个重要警告:不要给 BaseFinanceAgentFactory 加 @Component。
原因:AgentFactoryRegistry.start() 会把 Spring 容器里所有 BaseFinanceAgentFactory Bean 装进 staticIndex,然后校验每个 simpleName() 都能在 ac_agent_config 表里找到对应行。加了 @Component,Spring 会自动实例化一个 agentCode="default" 的默认 bean,validate 时找不到 ac_agent_config 里 agent_code='default' 的行,App 启动就炸了。
正确做法:由 DynamicAgentRegistry 手动 new BaseFinanceAgentFactory(...),或者在某 @Configuration 类里写 @Bean 方法按需返回。工厂实例的生命周期不在 Spring 容器管理,而在注册表管理------这是平台自建的装配管理,不是 Spring 的 Bean 管理。
小结
记住三件事:
- volatile 快照 :
AgentConfigSnapshot全字段 final 不可变,refreshFromDb()整体替换 volatile 引用实现热更新。createAgent()做一次 volatile 读后全程用局部变量------无锁热更新,旧请求不受影响,新请求立即生效。 - 十四个 Builder 开关 :名字、提示词、模型、中间件、状态存储、最大迭代、工作区、压缩、卸载、记忆、技能仓库、技能过滤、Plan 模式、任务列表。后置三个条件性开关:工具、HITL 权限、子代理。这些开关把 DB 里一行 JSON 配置 + 多张子表数据翻译成一个可运行的
HarnessAgent。 - per-request 权限重建 :快照固化全局 HITL 规则,但角色级规则按请求运行时注入------有 roleCodes 且有角色规则行时,
ToolRolePermissionResolver把角色覆盖叠加到 base 上。这是装配里唯一不靠快照固化的逻辑。
下一篇 进入模型路由------LlmConfigRegistry 怎么按 agentCode 找模型、多 ModelKey 路由、AtomicReference 快照、三态熔断器(CLOSED/OPEN/HALF_OPEN),以及 AesGcmApiKeyCipher 怎么用 AES-256-GCM 加密 API Key。