【AgentScope 2.0】05-从数据库配置到可运行 Agent:十个 Builder 开关怎么把一行 JSON 变成 HarnessAgent

源码地址:后端地址 前端地址

前两集讲了灰度路由怎么决定命中哪个版本的 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 行):每次 createAgentllmRegistry.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.mdMEMORY.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_filesgrep_filesglob_files 列入排除集------Javadoc 标注它们 "self-limiting outputs"(输出有上限),卸载反而有害。但实测证伪:这三个工具对大仓库返回全量匹配行、无任何上限FilesystemToolCollectors.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 里的 enablePlanenableTaskListenablePlan 开启 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);
}

四层逻辑:

  1. 快照固化的 base :启动时从 DB 加载的 HITL 规则(全局规则 + Agent 级规则),已经构建成 PermissionContextState 固化在快照里。
  2. 无角色就返回 basectx 里没有 roleCodes(评测 / 内部调用 / auth disabled),直接用快照值,行为零变化。
  3. 有角色但无覆盖也返回 base :有 roleCodes 但这个 Agent 没配角色级规则行(overrides 为空),避免无谓重建。
  4. 有角色规则就重建ToolRolePermissionResolver.resolveOverrides() 查这个 Agent + 这些角色的工具权限覆盖,rebuildPermissionContext() 把角色规则叠加到 base 上------被覆盖工具排除全局规则并注入角色规则,但 mode/workingDirectories/其余规则保留。

这个设计解决了什么问题?同一个 Agent,不同角色看到不同的工具审批策略。比如 execute_sql 工具,管理员角色配了 BYPASS(直接执行不审批),普通角色配了 ASK(每次执行要审批)。快照固化的全局规则是「ASK」,但管理员的请求会把 execute_sql 覆盖成 BYPASS------每次请求重建权限上下文,而不是启动时固化。第 13 集专讲 HITL 五级 scope 和审批机制。

快照里有什么

回头看 AgentConfigSnapshot,13 个字段,分三类:

DB 直接映射agentCodeagentNamesysPromptmodelParamsworkspaceDir------从 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 解析:temperaturetopPmaxTokensmaxItersenablePlanenableTaskListmodelNamefromJson() 方法(第 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_configagent_code='default' 的行,App 启动就炸了。

正确做法:由 DynamicAgentRegistry 手动 new BaseFinanceAgentFactory(...),或者在某 @Configuration 类里写 @Bean 方法按需返回。工厂实例的生命周期不在 Spring 容器管理,而在注册表管理------这是平台自建的装配管理,不是 Spring 的 Bean 管理。

小结

记住三件事:

  1. volatile 快照AgentConfigSnapshot 全字段 final 不可变,refreshFromDb() 整体替换 volatile 引用实现热更新。createAgent() 做一次 volatile 读后全程用局部变量------无锁热更新,旧请求不受影响,新请求立即生效。
  2. 十四个 Builder 开关 :名字、提示词、模型、中间件、状态存储、最大迭代、工作区、压缩、卸载、记忆、技能仓库、技能过滤、Plan 模式、任务列表。后置三个条件性开关:工具、HITL 权限、子代理。这些开关把 DB 里一行 JSON 配置 + 多张子表数据翻译成一个可运行的 HarnessAgent
  3. per-request 权限重建 :快照固化全局 HITL 规则,但角色级规则按请求运行时注入------有 roleCodes 且有角色规则行时,ToolRolePermissionResolver 把角色覆盖叠加到 base 上。这是装配里唯一不靠快照固化的逻辑。

下一篇 进入模型路由------LlmConfigRegistry 怎么按 agentCode 找模型、多 ModelKey 路由、AtomicReference 快照、三态熔断器(CLOSED/OPEN/HALF_OPEN),以及 AesGcmApiKeyCipher 怎么用 AES-256-GCM 加密 API Key。

相关推荐
2601_949499941 小时前
DT‑1414PTZ如何百分百兼容(安华高)HFBR‑1414PTZ
运维·网络·人工智能·科技·光模块
百胜软件@百胜软件1 小时前
胜券AI的Skills可插拔技能包,让零售AI从“泛”到“专”
大数据·人工智能·零售
瓶 盖1 小时前
AI 味为什么改词改不掉?把 61,608 篇小说拆开之后
人工智能
行走的领路人1 小时前
FlyEnv 本地开发环境与 AI 协同能力深度评测
人工智能
汇智信科1 小时前
基于 Jmis 框架的制度与流程管控系统设计与应用|项目全生命周期管理
大数据·人工智能·微服务·云原生·汇智信科
王红臣同学2 小时前
在 Windows 上复现 Microduck 机器鸭仿真
人工智能·ai
AI人工智能集结号2 小时前
AI推荐优化服务有哪些?不同服务类型适合什么需求?
人工智能
IT_陈寒2 小时前
Vue的computed属性竟然坑了我一把
前端·人工智能·后端
DO_Community2 小时前
Omarchy 将研发基础设施迁移至 DigitalOcean 云平台
linux·人工智能·llm·agent·omarchy