架构总览
适用端:后端 + Web | 类型:机制
定制功能
enlev-module-agent 整个模块都是本项目自研,上游 yudao 没有对应物。 它不是「在 enlev-module-ai 上加了几个类」,而是一个独立的 Maven 模块—— 这个选择本身是有理由的,理由写在 enlev-module-agent/pom.xml:15-24 的 <description> 里,本页第一节逐字引用。
本页是 03-agent 这一组的入口页:它只讲整个模块共用的架构前提 (模块边界、身份与租户、声明值 vs 生效值、架构测试、配置面、依赖钉版), 具体机制分散在后面七页。如果你只想改一个工具或加一条校验规则, 可以先去 工具体系 或 Plan 与人工确认; 但动手前值得读完本页的「声明值 vs 生效值」与「架构测试」两节—— 那两条是全模块共用的纪律,踩了会被测试撞红。
定位:说清「这个模块为什么长成这样」——边界画在哪、身份怎么跨线程、 哪一层在管安全、哪些结论是被测试钉住的而不是靠约定。 以及一份实测出来的规模与形状盘点,供你判断「我要改的东西大概在哪」。
前置阅读:后端架构与模块依赖(全仓模块划分与依赖方向)、 权限体系总览(三层权限,本页反复引用它的 fail-open 语义)。
它解决什么问题
为什么是独立模块,而不是并进 ai
pom.xml 的 <description> 把理由写成了可执行的约束,逐字如下:
AI 助手模块:工具体系、Agent 循环、Plan 预览确认与权限网关。
为什么独立成模块,而不是并进 enlev-module-ai:
ai 模块的提交全部来自上游且仍在活跃维护,agent 代码若与它同目录交织,
每次 merge 都要人工判断。独立成模块后,对 ai 的改动只有「新增 api/ 目录」这一处。
依赖方向是单向的:agent → ai.api / infra.api / bpm.api / system.api,
agent 是唯一的聚合层,不被任何模块反向依赖。这里有两个可以当场验证的断言,本页都验了:
- 「对 ai 的改动只有新增 api/ 目录这一处」——成立。见 ai 域开发。
- 「不被任何模块反向依赖」——成立,而且是实测的: 全仓
grep -rln "import cn\.enlev\.ainb\.module\.agent" --include=*.java排除 agent 自身目录后命中 0 个文件;pom.xml里引用enlev-module-agent这个构件的 也只有 3 处——它自己的 pom、enlev-server/pom.xml(启动装配)、根pom.xml(reactor 声明)。 ⟹ agent 是严格的叶子模块:它能调四个上游域,没有任何东西能调它。
「只走 api/」不是分层洁癖,它是一条安全结论
AgentArchitectureTest#testOnlyDependOnApiLayer(:256-303)的注释里写得很直白: 权限边界清单第 7 条(「让 AI 查询用户无权查看的数据 → 行权限生效」)当前验不出东西, 因为压根没有任何工具能拿到业务行数据——而这不是碰巧,是因为 agent 只能调其它模块的 api/ 层, 而那一层至今没有暴露任何返回业务行数据的方法。
所以这条测试挡的不是耦合,是「有人给 agent 开一条直连 Service 的口子, 而没人意识到那等于绕过了行权限」。攻击面一旦开出来,第 7 条立刻从「不可达」变成「未覆盖的真实风险」—— 因为行权限在拿不到 userId 时是放行的(DataRowFilterResolver:171-173), 字段权限同样(PermissionFieldHelper 类注释第 30 行「无登录上下文 = 不限制」), 两者都只打一条 warn。
规模与形状(2026-10-25 实测)
| 维度 | 数量 | 备注 |
|---|---|---|
main .java | 298 | 合计 61,041 行 |
test .java | 161 | 合计 62,840 行、2,225 个 @Test |
@RestController | 15 | 见下文「接口清单」 |
| 错误码 | 67 | 全在 enums/ErrorCodeConstants.java(876 行)一个文件里 |
| 数据表 | 16 | 与 16 个 *DO.java 严格 1:1,无孤儿表也无孤儿 DO |
@ConfigurationProperties | 8 | 全在 framework/agent/config/(同目录还有一个 @Configuration 的 AgentConfiguration,不在这 8 个里) |
| 定时任务 | 3 | job/ 下三个类 |
| 诊断端点 | 4 个 Controller | 735 行,见「坑与注意」 |
测试代码比生产代码还多(62,840 vs 61,041)。这不是凑数: 后面几页会反复看到「一条禁令配一条变异探针」的写法,那是刻意的双倍投入, 理由见下文「架构测试是这个模块的宪法」。
这些数字怎么来的
find ... -name '*.java' | wc -l 与 wc -l 直接数,@Test 用 grep -c。 不是从任何设计文档抄的——设计文档里的同类数字已经漂移过(例如早期方案写「~58,549 行」, 上一版本页记的是 58,845,本次实测 61,041)。
🔴 这张表每次后端提交都可能变,不要把它当常量引用。本次相对上一版的差额可以逐条归因: main +2 文件 / +2,133 行与 test +4 文件 / +3,061 行 / +118 个 @Test 来自 476b4b2637(补 ScriptSandboxTest、AgentToolCatalogTest、DataModel{Create,Update}ValidatorTest、 FormSaveDraftToolTest 等)与 1b1ac7b1e9(补 AgentDocMarkdownSanitizerTest、 AgentDocSyncServiceImplTest、AgentPromptBuilderTest);错误码 65 → 67 与诊断 Controller 721 → 735 行同样出自 1b1ac7b1e9(新增 /agent/diagnostic/doc-sync-status、doc-sync-verify 两个端点与配套错误码)。再往后 main +63 行 / test +88 行 / +2 个 @Test 出自 4093e19c7d (doSync 的「语料为空就别删」守卫 + 两条对应单测,文件数不变)。 Controller 数、表 ↔ DO、@ConfigurationProperties、job 数未变。
数 @Test 要加行首锚定:grep -ro '@Test' 在 agent 上会多数 1 个(javadoc 里的提及)。
代码在哪
后端根路径缩写 agent/ = enlev-module-agent/src/main/java/cn/enlev/ainb/module/agent/。
顶层目录
| 目录 | 文件数 | 职责 | 详见 |
|---|---|---|---|
agent/framework/ | 114 | 全部机制层:引擎、工具、校验、清单、安全、提示词 | 下面细分 |
agent/controller/ | 57 | 15 个 Controller + 各自的 VO | 接口清单 |
agent/service/ | 41 | 编排与落库 | 各专题页 |
agent/tool/ | 36 | @AgentTool 标注的内置工具实现 | 工具体系 |
agent/dal/ | 32 | 16 个 DO + 16 个 Mapper | 数据模型 |
agent/enums/ | 12 | 错误码、场景枚举、风险级等 | — |
agent/job/ | 3 | 保留期清理 + 两个调度器 | 工作流引擎 |
agent/listener/ | 1 | ⚠️ 见下面的警告 | — |
framework/agent/core/ 细分
| 子包 | 文件数 | 职责 |
|---|---|---|
core/engine/ | 33 | DSL 编译(compiler/ 3)、节点执行器(executor/ 12)、DSL 模型(dsl/ 7)、触发器(trigger/ 3)、支撑(support/ 3)、上下文(context/ 2)、trace / io / config 各 1 |
core/manifest/ | 27 | 变更清单与预演(ChangeManifestDryRunner 1122 行) |
core/validate/ | 15 | DSL 校验(WorkflowDslValidator 5800 行)与表单 schema 校验(981 行) |
core/tool/ | 10 | 注解、注册表、执行器、风险级、目录(AgentToolCatalog,476b4b2637 新增) |
core/chat/ | 7 | 提示词装配、答案切分、建议解析 |
core/security/ | 4 | OutputModerator、UntrustedInputSanitizer、PromptDataFence、AgentWebhookRateLimiter |
core/loop/ | 3 | agent_loop 节点的两道闸与提示词 |
core/gateway/ | 2 | AgentSecurityGateway + AgentExecutionContext |
core/react/ core/plan/ core/explain/ core/copy/ | 各 1 | ReAct 图、Planner 提示词(1170 行)、节点树解释、复制改写 |
framework/agent/config/ | 9 | 8 个 @ConfigurationProperties + 1 个 AgentConfiguration |
framework/agent/core/AgentTaskKeys.java | 1 | 图 state 的键名常量 |
framework/ 不是一个「无 I/O 的纯机制层」
很容易按名字推断「framework = 不碰数据库」。实测不成立: framework/ 下有 10 个文件直接 import 了 dal.mysql 或 dal.dataobject:
| 文件 | import 处数 |
|---|---|
core/validate/WorkflowDslValidator.java | 3 |
core/engine/trace/NodeTraceRecorder.java | 2 |
core/engine/trigger/FormDataChangedTriggerListener.java | 2 |
core/chat/AgentPromptBuilder.java | 1 |
core/engine/compiler/DslToStateGraphCompiler.java | 1 |
core/engine/trigger/WorkflowTriggerExtractor.java | 1 |
core/engine/trigger/WorkflowTriggerInvoker.java | 1 |
core/gateway/AgentSecurityGateway.java | 1 |
core/manifest/WorkflowChangeManifestBuilder.java | 1 |
core/plan/WorkflowDslPlannerPrompt.java | 1 |
其中 AgentSecurityGateway 注入的是 AgentConfigMapper(第 4 步查租户总开关), WorkflowDslValidator 要查执行史来决定某条规则是拦还是降档。 这些都是合理的,但结论是:给 framework/ 写单测时不能假设它无依赖, WorkflowDslValidator 的测试就必须 mock Mapper。
listener/ 这个目录名会骗人
agent/listener/ 下只有 1 个文件(AgentBpmApprovalListener.java), 但本模块实际上有 2 个事件监听器——另一个是 framework/agent/core/engine/trigger/FormDataChangedTriggerListener.java。
按目录名找监听器会漏掉一半。别为了「整齐」把它挪进 listener/: 它属于工作流触发器那一套,与同目录的 WorkflowTriggerExtractor / WorkflowTriggerInvoker 是一个整体,挪走反而拆散了。
最大的几个文件
| 行数 | 文件 | 一句话 |
|---|---|---|
| 5800 | core/validate/WorkflowDslValidator.java | V 系列全部 DSL 校验规则(34 个号),是第 2 名的 3.6 倍 |
| 1607 | service/chat/AgentAssistantServiceImpl.java | 四条路由的装配与落库 |
| 1363 | core/engine/compiler/DslToStateGraphCompiler.java | DSL → Spring AI Alibaba Graph |
| 1258 | service/workflow/AgentWorkflowDefServiceImpl.java | 定义 CRUD、发布、版本 |
| 1232 | core/plan/WorkflowDslPlannerPrompt.java | Planner 提示词装配(core/plan/ 的唯一文件) |
| 1177 | tool/impl/form/FormUpdateTool.java | 表单更新工具 |
| 1122 | core/manifest/ChangeManifestDryRunner.java | 变更清单预演,全模块唯一允许查库的清单类 |
5800 行的校验器是刻意的,不要拆
WorkflowDslValidator 一个文件装了 V 系列全部规则。看着该拆,但拆了会破坏两件事:
- 号段的权威只有一份。前端
PublishGateDialog.vue的注释明写 「号段与生效条数的权威只有一份,在后端WorkflowDslValidator的类 javadoc 里」。 拆成 34 个类,这句承诺就没有落点了。 - 架构测试按目录扫。
DECLARED_RISK_ONLY_DIRS钉的是framework/agent/core/validate这个目录,拆到别的目录会静默脱离扫描域 (而testDeclaredRiskOnlyOnGatePaths:359-362只在「整个目录空了」时才撞红, 拆走一半它是绿的)。
要加规则就往这个文件里加,加完去号段表登记。
⚠️ 但那份「权威」不在类 javadoc 里——前端那句注释记错了位置。实测它在 WorkflowDslValidator:5349-5441, 也就是埋在 V20(validateChatLegsWriteReply)自己的 javadoc 里(本页上一版记的 :4903-4993 已随文件增长漂移了 400 多行)。号段口径本身是 V1~V16 + V18~V35,共 34 个号;V17 结构上不存在;下一个可用号是 V36(:5361 自己钉的)。 完整论证与「为什么全文 grep 必然多数」见 Plan 预览确认与 HITL · V 号段。
🔴 号段是活的:V33 / V34 / V35 是本页上一版之后才补的,「下一个可用号」也随之从 V33 变成 V36。 引用号段前先按它自己规定的数法重数(:5399-5406:数 section 分隔条 // ==== …(V..)==== 与各规则 javadoc 标题里的「本条是 Vxx」),别拿全文 grep 'V[0-9]'—— 权威段自己就写着十几个号,全文 grep 会把自己的说明文字一起数进去。
核心机制
一、身份快照:整个模块的脊柱
这是理解 agent 模块的第一把钥匙。后面每一页都会用到它。
AgentExecutionContext(framework/agent/core/gateway/,213 行)做的事一句话: 把 userId / tenantId / loginUser 从 ThreadLocal 里取出来存成普通值,之后在任何线程上都能显式重建—— 不再依赖「当前线程恰好有上下文」。
它的类注释写着「有实测支撑,不是防御性冗余」,而那份实测就固化成了一个可复现的端点 AgentDiagnosticController(324 行)。逐字抄它 :39-47 的结论表:
| 采样点 | 线程 | userId / tenantId | 为什么 |
|---|---|---|---|
| controller-entry | http-nio-exec | ✅ 拿得到 | Servlet 线程,直接读 ThreadLocal |
| flux-sync-emit | 订阅线程 | ✅ 拿得到 | 同步发射,没换线程 |
| flux-async-emit | parallel-N | ✅ 拿得到 | Reactor 上下文传播在调度切换时搬运了快照 |
| external-thread-emit | 请求期间 new 的裸线程 | ✅ 拿得到 | TTL 继承自 InheritableThreadLocal,创建时继承 |
| prestarted-pool-emit | 预热线程池 | ❌ 都是 null | 线程早于请求创建,继承不到;未经 TTL 装饰 |
这份实测还推翻了一个原本的归因(:49-56 逐字):
原先推断「
TenantContextWebFilter在 finally 里 clear(),而 Servlet 异步下 filter chain 先返回,所以上下文丢失」。实测否定了这条: 外部线程 sleep 800ms(filter 的 finally 早已执行)后依然读得到—— 因为 TTL 是继承语义,子线程持有的是自己那份副本,父线程清理不影响它。真正的风险点是线程池复用:池化线程在应用启动时就已创建,继承不到任何请求上下文。 HTTP 客户端的 IO 线程正是这种形态。
那既然「大多数情况拿得到」,为什么还要显式快照?:58-68 给了两条理由,都值得逐字读:
- Reactor 自动上下文传播——项目里没有任何一行显式配置, 它来自
io.micrometer:context-propagation被自动装配。 依赖一个没人显式声明过的行为,等于把租户隔离押在框架默认值上。 - 上下文是可变且会漂移的——实测中在
parallel-1上写入的哨兵值, 被原样传播到了parallel-2。链路上任何一环改了上下文,后续全都跟着变。
这一节的实操结论
不要写「异步链路上应该能拿到租户吧」。要么在 Servlet 线程上调 AgentExecutionContext.snapshot(),要么用 ofSystem(userId, tenantId) 从持久化的执行实例重建, 然后在目标线程上 context.execute(() -> ...)。拿不到身份就拒绝执行,不要降级放行。
二、execute() 为什么不用框架自带的 TenantUtils.execute
AgentExecutionContext:155-183 自己管租户上下文,:160-166 的注释逐字解释了为什么:
// 🔴 刻意不用 TenantUtils.execute(tenantId, Callable):
// 它在 catch 里把所有异常<b>包装成 RuntimeException</b>(TenantUtils#execute)。
// 那会毁掉两件事:
// ① ServiceException 被包装后,全局异常处理器认不出来,
// 用户看到的是「系统异常」而不是「您没有执行该操作的权限:…」
// ② AI 的自修正依赖准确的错误信息,包装后拿到的是一层无意义的外壳
// 所以这里自己管理租户上下文 —— 逻辑与 TenantUtils 一致,只是不吞掉异常类型。第 ② 条是 agent 特有的:AI 会读错误信息然后自己改。 把 ServiceException 包成 RuntimeException 之后,模型拿到的是一句没有信息量的外壳, 自修正就退化成瞎猜。这是「一个看似无害的框架便利方法,在 AI 场景下变成了缺陷」的典型。
同一个方法里还藏着第二个陷阱,setLoginUserWithoutRequest:185-201:
⚠️ 不能直接用
SecurityFrameworkUtils.setLoginUser(user, null): 它内部走WebAuthenticationDetailsSource.buildDetails(request), 而后者会读request.getRemoteAddr()——传 null 直接 NPE。那个方法隐含假设「设置身份这件事总发生在一次 HTTP 请求里」, 而 agent 的执行恰恰可能落在与请求无关的线程上。
finally 里的还原也有讲究(:171-181):用 SecurityContextHolder.clearContext() 而不是 setLoginUser(null, ...)——后者会构造一个 principal 为 null 的 Authentication 塞进去, 那不是「没有身份」,那是「有一个空身份」。
三、六步网关,以及为什么对话入口只跑四步
AgentSecurityGateway(176 行)是所有工具执行的唯一入口,类注释第一句就是 「任一条不满足就直接拒绝并终止——不降级、不继续」。
| 步 | 检查 | 失败抛 | 位置 |
|---|---|---|---|
| 1 | context 与 userId 非空 | AGENT_NO_LOGIN_CONTEXT | checkIdentity:98-101 |
| 2 | tenantId 非空 | AGENT_NO_TENANT_CONTEXT | :102-104 |
| 3 | 不处于跨租户状态 | AGENT_CROSS_TENANT_DENIED | :113-117 |
| 4 | 租户级总开关(查得到配置行) | AGENT_DISABLED | checkEnabled:125-129 |
| 5 | 工具存在且已启用 | AGENT_TOOL_NOT_FOUND / AGENT_TOOL_DISABLED | checkTool:139-173 |
| 6 | 声明的每个权限串都有 | AGENT_PERMISSION_DENIED | :152-156 / :169-173 |
check(context, toolName) 跑 1~6;checkChat(context) 只跑 1~4。为什么对话也要过闸门, :76-84 逐字:
一次对话未必会调到工具(比如模型直接作答,或者检索那一步失败了), 那样就一个
check都不会跑——身份是否完整、总开关是否开着, 全都没人验过,而 LLM 已经被调用、token 已经被消耗。
同一个 javadoc 还划清了一条分工,值得单独记住:
⚠️ 这里不校验
agent:assistant:use权限,那一条由 Controller 的@PreAuthorize负责。 刻意不在这里重复:两处都写,将来改权限串时很容易只改一处, 而「改了却还有一处在用旧串」的表现是随机的 403,极难查。 分工是明确的——入口权限归 Controller,工具权限归网关。
第 3 步(跨租户)的理由不是「不方便」而是「校验会失效」(:109-112): SecurityFrameworkUtils.skipPermissionCheck() 在 visitTenantId != tenantId 时返回 true, 功能权限与数据权限会被一起短路。会话若在这个状态下发起,第 6 步等于没做。
第 4 步的口径也值得抄下来(:120-124): 「查不到配置行就是未启用——『默认关着』是数据层面的事实,不靠默认值写对」。
第 5~6 步是 registry-first(:131-137):先查代码工具注册表,命中则走逐字节不变的原路径; 查不到才回落到用户自定义工具目录 agent_tool_def,镜像同一套校验。 「代码工具永远优先——即便有人漏了登记时的重名校验,用户工具也抢不到代码工具的名字」。 权限是 AND 语义(全都有才放行),:149-151 的理由: 「只查第一个的话,用户会卡在链条中间——而中间态往往比没做更糟」。
四、声明值 vs 生效值:全模块最重要的一条纪律
AgentToolRegistry.ToolDefinition(:124-278)把每个工具属性分成两半:
- 声明值:
declaredPermissions/declaredRisk/declaredEnabled/ … 全是private final,来自@AgentTool注解,代码即事实,跨租户共享,不可变。 - 生效值:
getPermissions()/getRisk()/isEnabled()/ … 每次调用都override()现取现算,可被agent_tool_override一行数据改写。
于是「读哪一个」成了一个安全决策。实测结论(grep 全 main):
| getter | 非注释调用点 | 分布 |
|---|---|---|
ToolDefinition#getRisk()(生效) | 7 处 | AgentConfigController:113,114,131、AgentGatewayDiagnosticController:82、AgentToolOverrideServiceImpl:327,328、AgentNameCatalogServiceImpl:128 |
ToolDefinition#isRequireConfirm()(生效,内部走 getRisk()) | 4 处 | AgentConfigController:116、AgentGatewayDiagnosticController:84、AgentToolOverrideServiceImpl:331、AgentNameCatalogServiceImpl:132 |
getDeclaredRisk()(声明)用在判定上 | 7 处 | AgentChatToolLoop:272、AgentLoopGraphBuilder:215 与 :455(两道闸)、WorkflowChangeManifestBuilder:283、WorkflowDslPlannerPrompt:792、WorkflowDslValidator:5279-5280、AgentDefServiceImpl:426 |
getDeclaredRisk()(声明)用在展示上 | 5 处 | AgentConfigController:127、AgentToolExecutor:135、AgentToolOverrideServiceImpl:338 与 :382、AgentNameCatalogServiceImpl:129 |
11 处生效值调用,全部落在 4 个「给管理员看」的文件里——管理页 tool-list、 设计器名称目录、诊断端点、覆盖维护的合并视图。这与 AgentToolOverrideServiceImpl:61-62 的自陈完全吻合:
生效值
getRisk()/isRequireConfirm()在 main 里的调用方全是界面 (管理页 tool-list、设计器名称目录、诊断端点、本类的合并视图)。
而7 处判定路径全部读声明值,且形状完全一致——null 一律回落 RiskLevel.HIGH(fail-close):
RiskLevel declaredRisk = def.getDeclaredRisk() == null ? RiskLevel.HIGH : def.getDeclaredRisk();这 7 处手写三元是有名字的理由的:「宁可多说风险」
RiskLevel 上有一个 parseOrHigh(String raw)(RiskLevel.java:108), 但它接的是字符串——服务于自定义工具那条路(agent_tool_def.risk_level 是个字符串列, AgentToolDefServiceImpl:149 用它)。内置工具那条路拿到的是可能为 null 的 RiskLevel, 没有对应的 helper,于是 7 个文件各自手写了同一个三元。
回落方向不是随手挑的,WorkflowChangeManifestBuilder:281-282 把它写成了口径:
declaredRisk理论上不会为 null(注解必填、自定义那侧走parseOrHigh)。真为 null 时按 HIGH 兜—— 与RiskLevel.parseOrHigh的回落方向一致:宁可多说风险,不能把写操作标成只读。
WorkflowDslValidator:5723-5724(在 ToolResolver#declaredRiskOf 的 javadoc 里)重复了同一句(「认不出的工具按 RiskLevel#HIGH 兜, 与 RiskLevel.parseOrHigh 同方向」)。
口径只有这两份文字,实现有 13 处,方向全部一致(数法:parseOrHigh( 的调用点 6 处 ——WorkflowChangeManifestBuilder:290,546、WorkflowDslPlannerPrompt:891、AgentToolCatalog:237、 AgentToolDefServiceImpl:149、AgentNameCatalogServiceImpl:163;显式 RiskLevel.HIGH 兜底 7 处 ——WorkflowChangeManifestBuilder:283、WorkflowDslPlannerPrompt:854、AgentToolCatalog:224、 WorkflowDslValidator:5661,5666,5728,5737。@AgentTool(risk = RiskLevel.HIGH) 那 4 处是声明不是兜底,不计)。 本页上一版写「七处实现」,差额主要是 476b4b2637 新增的 AgentToolCatalog 一个文件就占了 3 处。
两种「放松」的代价不一样重,别混为一谈
这是 AgentToolOverrideServiceImpl:56-71 的实测口径,也是本页最该记住的一条:
- 降风险:不改变任何强制行为,只改变界面上的风险标注。 因为全部运行期闸——
WorkflowDslValidator的 V3/V11、WorkflowDslPlannerPrompt的工具目录、AgentLoopGraphBuilder的两道闸、AgentChatToolLoop、AgentDefServiceImpl的发布判定、AgentToolExecutor的沙箱 mock 判定、WorkflowChangeManifestBuilder——读的都是getDeclaredRisk()。 真正要紧的后果反而是界面误导:设计器把它标成「无需确认」,用户放进 agent_loop, 发布期被 V3 按声明值拒。 - 删权限:是真放松。
AgentSecurityGateway#checkTool逐个权限 AND 判定时读的是 生效值getPermissions()(AgentToolRegistry:243-246), 删空之后「调用只需登录」立刻对 AI 生效。
失效方向:降风险 = 界面骗人(拦得住,但人会困惑);删权限 = fail-open(真放行)。
同一个陷阱在两处各写了一遍注释
AgentConfigController:125 与 AgentNameCatalogServiceImpl:119 都写着 「declaredRequireConfirm 也不能图省事直接调 def.isRequireConfirm():那个 getter 内部走 getRisk()」。 两份注释重复不是疏忽——这两个类相隔很远,各自都容易被顺手写错, 而写错的表现是「管理员明明没维护,界面却显示成维护后的值」,很难联想到 getter 选错了。
五、架构测试是这个模块的宪法
AgentArchitectureTest(src/test/java/.../framework/,472 行、8 个 @Test) 用源码文本扫描而不是 ArchUnit 守五条禁令。为什么这么选,类注释 :25-27 逐字:
这几条禁令光靠约定挡不住——它们要防的恰恰是「照着隔壁模块抄」这个最自然的动作。 用源码扫描而不是 ArchUnit,是因为要拦的是字面用法而非类型依赖, 扫文本更直接、也不必为此引入新依赖。
| # | 测试方法 | 禁令 |
|---|---|---|
| 1 | testNoTenantExecuteIgnore:63-81 | 禁 TenantUtils.executeIgnore——它把租户隔离整个关掉 |
| 2 | testNoTenantIgnoreAnnotation:151-193 | 禁 TenantIgnore(注解形式,与 1 是同一个洞) |
| 3 | testTenantIgnoreAllowlistIsNotStale:206-230 | 反向自检:白名单那一处必须还在、处数没变、且真的是注解形式 |
| 4 | testNoCustomTablePrefixConstants:232-254 | 禁自定义系统表前缀常量——名单只能有一份 |
| 5 | testOnlyDependOnApiLayer:256-303 | 跨模块只能走 api/ 层 |
| 6 | testScannerActuallyCoversSources:305-315 | 反向自检:扫描确实覆盖到了源码,防止测试空跑 |
| 7 | testDeclaredRiskOnlyOnGatePaths:352-403 | 校验路径与 Planner prompt 装配路径禁 getRisk() / ToolDefinition#isRequireConfirm() |
| 8 | testDeclaredRiskActuallyUsedOnGatePaths:413-431 | 反向自检:那两条路径确实还在读 getDeclaredRisk() |
8 条里有 3 条是反向自检。这不是过度设计,:29-32 把理由写死了:
🔴 每条禁令都配一句「反向自检」。文本扫描有个共通的失效方式: 把要守的代码删掉,禁令就更绿(判定没了,违规自然没了;目录挪走了,
Files.walk什么都不返回)。 所以每条禁令旁边都钉一句「那段东西必须还在 / 那个目录必须扫得到」, 让「静默失效」变成「撞红」。方案 §4.2 管这叫变异探针。
几个值得单独抄下来的细节:
- 白名单钉「恰好几处」而不是「这个文件豁免」(
:85-95)。 全模块只有 1 个文件被豁免:job/AgentUsageLogRetentionJob.java,恰好 2 处 (实测::4的 import 行 +:49的注解行;:27那行是 javadoc,被isCommentLine跳过)。 「文件级豁免正是新违规的藏身处——谁在同一个文件里再开一处@TenantIgnore,禁令照样绿」。 - 注解形式比
executeIgnore更宽(:176-180):@Target含 TYPE 且@Inherited, 贴在 DO 类上等于把整张表加进ignoreTables、贴在 Controller 类上等于把整个 URL 加进ignoreUrls(infra 的CodegenTableDO/ConfigDO/FileConfigDO就是这么用的); 它还有一个支持 SpEL 的enable()。这几种形状字面量扫executeIgnore一个都看不见—— 这就是缺陷 E11 的来历。 - 刻意扫裸 token 而不是
@TenantIgnore(:118-132):只扫带@的会漏掉 import 行与全限定名写法。 代价是标识符名字里含这四个词的也会命中(变异探针那次就撞上了:探针类名叫MutationProbeTenantIgnore,类声明行被一并报出,预期 2 处实际报了 3 处)。 这里刻意不收窄成正则:「多报一个名字里带 TenantIgnore 的类」的代价是改个名, 而「少报一种没想到的写法」的代价是隔离被静默关掉。命中了但不是注解用法时,改名即可,别加白名单。 - 表前缀禁令有一段真实事故背书(
:243-250): 「这份名单在 S0 之前后端有 5 份副本、三套口径,其中表单设计那 3 份只认system_/infra_/bpm_—— 于是sys_grant能被绑定为可写主表,绕过整个权限判定链」。 正确做法是调SystemTablePolicy.isAllowed(tableName, TableOperation.XXX)(该类在enlev-spring-boot-starter-mybatis,不在 agent 模块), 按操作类型取口径:READ 无限制 / WRITE 8 个前缀 / DDL 与 REGISTER 24 个。 isCommentLine:467-470的口径偏严:整行是注释才跳过,行尾注释不跳。 「宁可误报也别漏报」。
架构测试里的行号引用已经漂移(2026-10-25 复核)
这条测试自己在注释里引了四处外部行号,四处都已失效:
| 引用处 | 注释里写的 | 实测 |
|---|---|---|
:264 | DataRowFilterResolver:144-146 | :173 的 if (userId == null)(文件 837 行) |
:265 | PermissionFieldHelper:80-87 | :79-85(loginUserId == null → unrestricted()) |
:334 | WorkflowDslValidator:693 | :952 的 if (declared.isRequireConfirm())(文件 5800 行) |
:442 | WorkflowDslValidator:2773-2774 的跨行三元 | 这个形状已经不存在了:全文件搜不到任何跨行三元,现存两处声明风险级取值都是单行(:951、:3051 的 RiskLevel declared = tools.declaredRiskOf(toolId));被扫的另一个目录 core/plan 里 WorkflowDslPlannerPrompt:854 是单行三元。「连下两行一起看」因此退化成防御性写法,不再对应任何活例子 |
禁令本身全部有效(扫的是字面量,不依赖行号),失效的只是给人看的出处。 失效方向:静默——它只会让后来人按图索骥时找不到地方,不会让任何一条禁令变松。
🔴 本页上一版把第三行记成「实测 :939」、第四行记成「实测 :5279-5280」,那是当时的真值, 但**「实测值」本身也会随后端提交继续漂**:WorkflowDslValidator 从 5341 行涨到 5800 行, 第四行指向的代码更是直接被重构掉了。所以别把这张表当权威——要引用行号就现查, 或者改成引用方法名 / 常量名(isRequireConfirm 的白名单接收者是 DECLARED_RISK_RECEIVERS, 这个名字比行号稳)。
六、配置面:8 个 @ConfigurationProperties,但前缀不统一
framework/agent/config/ 下 8 个配置类,前缀一律 enlev.agent.*:
| 类 | 前缀 | 关键默认值 |
|---|---|---|
AgentChatProperties | enlev.agent.chat | suggestionEnabled=true、streamTimeoutSeconds=120、titleMaxLength=20 |
AgentDocProperties | enlev.agent.doc | autoDetectMarker="ainb-ui-admin-vue3/docs"、autoDetectMaxDepth=5、topK=5、similarityThreshold=0.2、splitStrategy="markdown_qa" |
AgentIntentProperties | enlev.agent.intent | 意图分类 |
AgentLoopProperties | enlev.agent.loop | agent_loop 节点 |
AgentPlanProperties | enlev.agent.plan | 规划轮数上限 |
AgentSanitizeProperties | enlev.agent.sanitize | chatMaxLength=4000、webhookBodyMaxLength=32768、formEventValueMaxLength=8192、retrievalSegmentMaxLength=4000、toolResultMaxLength=16384、maxDepth=16 |
AgentUsageProperties | enlev.agent.usage | inputTextRetentionDays=180 |
AgentWebhookProperties | enlev.agent.webhook | rateLimitEnabled=true、rateLimitIpCount=60、rateLimitIpWindowSeconds=60、rateLimitPathCount=300 |
两个调度器用的是另一个前缀
job/WorkflowScheduleTriggerScheduler.java:87 与 job/WorkflowWaitResumeScheduler.java:86:
@Scheduled(fixedDelayString = "${agent.workflow.schedule-trigger-interval-ms:5000}", ...)
@Scheduled(fixedDelayString = "${agent.workflow.wait-poll-interval-ms:5000}", ...)前缀是 agent.workflow.*,不是 enlev.agent.*,也没有对应的 @ConfigurationProperties 类 (直接写在 @Scheduled 的 SpEL 里)。
后果是静默的:你在 application.yaml 里配 enlev.agent.workflow.schedule-trigger-interval-ms 不会报错、也不会生效,轮询间隔悄悄留在 5000ms。 失效方向:静默。要调这两个间隔,配 agent.workflow.schedule-trigger-interval-ms 与 agent.workflow.wait-poll-interval-ms。
两处「要不要留总开关」的取舍正好相反,都是刻意的,值得对照着读:
| 组件 | 有总开关吗 | 理由(逐字摘自类注释) |
|---|---|---|
AgentWebhookProperties.rateLimitEnabled | 有 | 「限流器依赖 Redis、Redis 故障时现场需要一个不回滚代码就能恢复通行的止血手段」;但「🔴 关掉之后本端点就回到『谁都能打』的状态,这是知情后的取舍,不是无副作用的开关」 |
AgentSanitizeProperties | 刻意没有 | 「本组件没有那种故障模式:它是纯字符串处理,不碰任何外部依赖,不会『自己成为故障源』。于是关掉它能换来的只有『控制字符不再被剥、超长内容不再被截』——那不是止血,是把防线拆了」 |
AgentSanitizeProperties 的按源分档也同样有理由(:10-15): 五个污点源的「合理长度」差着两个数量级——检索片段是被切过块的,几千字符就是异常; webhook body 是机器对机器的整张订单 JSON,几万字符完全正常。给同一个数只有两种结局: 「取小值会把合法的 webhook 业务数据截成半截(功能坏掉,且没人报错), 取大值等于对检索片段不设防(而检索片段恰恰是最容易被塞进 prompt 的那一类)」。
maxDepth=16 那一条的定性最要紧(:95-104):
🔴 这一条不是「精细度」参数,是「别把线程搞死」参数。 webhook body 的嵌套深度由调用方决定,无界递归会在图执行线程里抛
StackOverflowError—— 那不是Exception,执行层那些catch (Exception)一个都拦不住。 ……🔴 超限的处置是丢弃该子树并记 warn,不是「原样保留」—— 原样保留等于让一个没被处置过的值进了 state,那是这道闸唯一不能有的形状。
七、依赖钉版:两处刻意的「不要修正」
pom.xml:27-33 逐字:
<!-- 🔴 与 enlev-module-ai 对齐:spring-ai 锁 1.1.8。
spring-ai-alibaba-graph-core:1.1.2.2 传递声明的是 spring-ai 1.1.2,
若不在此用 bom 强制,Maven 仲裁会让 graph-core 分支的 1.1.2 覆盖 ai 模块的 1.1.8
(实测 dependency:tree -Dverbose:1.1.8 被 "omitted for conflict with 1.1.2"),
而 ai 模块 + dashscope starter 是按 1.1.8 编译的,降级到 1.1.2 有 NoSuchMethodError 风险。
P0 spike 已验证 graph-core 1.1.2.2 运行在 spring-ai 1.1.8 上正常。 -->
<spring-ai.version>1.1.8</spring-ai.version>pom.xml:71-75 逐字(graph-core 为什么锁 1.1.2.2):
🔴 版本锁定 1.1.2.2,与 enlev-module-ai 的 spring-ai-alibaba-starter-dashscope 同版本线: 2.0 线需 Boot 4.0,与本项目 3.5.15 不兼容,只随 1.x 安全补丁升级,不跨 2.0。
GraalJS 是显式声明而不是靠 infra 传递进来的,:82-84 的理由值得抄:
本模块直接
import org.graalvm.polyglot.*,哪天 infra 不再依赖它, 这里会编译失败而不是运行期莫名 ClassNotFound。
这三处都在根 AGENTS.md 里被标了「不要修正」
spring-ai-bom 钉版尤其容易被 IDE 的「依赖优化」提示带走。 删掉它的表现不是编译失败,而是运行期 NoSuchMethodError——离原因很远。
数据模型
16 张 agent_* 表,与 16 个 *DO.java 严格 1:1(实测 @TableName 值集合与 sql/mysql/ainb-init.sql 的 CREATE TABLE 集合完全相同,无孤儿表也无孤儿 DO)。
| 分组 | 表 | 一句话 |
|---|---|---|
| 对话 | agent_conversation / agent_message | 会话与消息,agent_message.status 有 SUCCESS / FAILURE / CANCELLED 三态 |
| 智能体 | agent_def | 智能体定义(绑工具、绑提示词) |
| 工作流 | agent_workflow_def / agent_workflow_execution / agent_node_execution | 定义(含版本)、执行实例、节点 trace |
| 规划 | agent_planner_adoption | 草稿被采纳的指标(baseline_dsl 是全仓唯一被冻结的 Plan 副本) |
| 工具 | agent_tool_def / agent_tool_override / agent_tool_override_audit | 自定义工具目录、内置工具在线覆盖、覆盖审计 |
| 计量 | agent_usage_log / agent_model_price / agent_quota | 计费证据、价目、配额 |
| 配置 | agent_config / agent_prompt_template / agent_prompt_template_case | 租户总开关、提示词模板与样例 |
ChangeManifest 不在这个列表里,这是刻意的
变更清单(core/manifest/,27 个文件)永不存库。 唯一例外是 hitl.bpm_approval 节点会把它冻结进 Flowable 流程变量 agent_change_manifest。 理由与结构见 Plan 与人工确认。
agent_tool_override 上没有唯一索引,而且加不上
AgentToolOverrideServiceImpl:78-89 逐字:
deleted是软删标记,「恢复默认」再「重新维护」留下的墓碑会继续占着同一个(tenant_id, tool_name)(实测本仓datamodel_create名下已积 28 行墓碑)。 于是并发保存能落下两行 live,而旧实现用selectOne读它—— 多行时永久抛TooManyResultsException,且update与reset都走这个查询, 等于「保存」与「恢复默认」两个按钮一起坏,UI 里没有出路。
现在的处置是「读到时自愈」:保留最新一行、软删其余并 log.error。 三处读快照必须与自愈保留的是同一条,否则自愈本身就会改掉工具的生效配置。
接口清单
15 个 @RestController,全在 controller/admin/ 下:
| Controller | 路径前缀 | 说明 |
|---|---|---|
assistant/AgentAssistantController | /agent/assistant | 对话、建议、3 条 SSE 流 |
workflow/AgentWorkflowDefController | /agent/workflow-def | 定义 CRUD、发布、版本、/copy |
workflow/AgentWorkflowController | /agent/workflow | 执行、run-stream、resume-stream、执行史、/event |
planner/AgentWorkflowPlannerController | /agent/planner | 草稿生成 run-stream、取草稿、取清单、采纳 |
def/AgentDefController | /agent/def | 智能体定义 |
tooldef/AgentToolDefController | /agent/tool-def | 自定义工具 + 内置工具在线维护 |
prompttemplate/AgentPromptTemplateController | /agent/prompt-template | 提示词模板 |
prompttemplate/AgentPromptTemplateCaseController | /agent/prompt-template-case | 模板样例 |
config/AgentConfigController | /agent/config | 租户总开关、工具生效值/声明值合并视图 |
usage/AgentUsageController | /agent/usage | 用量看板与明细 |
webhook/AgentWebhookController | /agent/webhook | @PermitAll,模块唯一的匿名面 |
diagnostic/AgentDiagnosticController | /agent/diagnostic | SSE 上下文实测证据(本页第一节那张表) |
diagnostic/AgentGatewayDiagnosticController | /agent/gateway-diagnostic | 网关判定链逐步骤回放 |
diagnostic/AgentApiDiagnosticController | /agent/api-diagnostic | 跨模块 api/ 调用面盘点 |
diagnostic/AgentRagChatDiagnosticController | /agent/rag-chat-diagnostic | 检索 + 对话联调 |
匿名面只有一处,但它真的是匿名的
POST /admin-api/agent/webhook/{path} 标了 @PermitAll——不需要登录、不需要租户。 它是整个 agent 模块唯一能被未认证请求打到的端点,因此也是限流与验签唯一真正承重的地方。 两道闸的口径见 安全与提示注入。
4 个诊断 Controller 是「把实测证据固化成端点」
735 行诊断代码不参与任何业务链路。它们存在的意义是: 当一个设计前提靠「实测」支撑时,把那次实测做成可复现的端点, 下一个人不必重新搭一遍环境就能验证前提还成不成立。 本页「核心机制 · 一、身份快照」那张 5 行表就是 AgentDiagnosticController 的类注释——它既是文档也是可执行的证据。
怎么扩展
加一张表
sql/mysql/ainb-init.sql加CREATE TABLE agent_xxx(不要新建xpy-*.sql,见「坑与注意」第 9 条)。dal/dataobject/xxx/AgentXxxDO.java加@TableName("agent_xxx")。dal/mysql/xxx/AgentXxxMapper.java继承BaseMapperX。- 🔴 如果这张表要跨租户读,先读
AgentArchitectureTest的TENANT_IGNORE_ALLOWED—— 豁免的门槛是「逐行自证 + 只销毁不外泄 + 线程上没有当前租户」三条同时满足, 而且要在白名单里把理由与恰好处数一起写清楚。 「读租户数据再据此行动」的活一律用TenantUtils.execute(tenantId, ...)逐租户跑, 照WorkflowScheduleTriggerScheduler抄。
加一个 Controller
- 建在
controller/admin/<域>/,VO 放同级vo/。 @PreAuthorize("@ss.hasPermission('agent:xxx:yyy')")——入口权限归 Controller, 不要在AgentSecurityGateway里重复校验(理由见「核心机制」第三节)。- 权限串要在
sql/mysql/ainb-init.sql的system_menu里登记, 否则授权页上勾不到。 - 🔴 如果要返回 LLM 产物,过
OutputModerator。注意moderateString刻意不幂等, 同一段文本审两次会叠两个前缀。 - 错误码加进
enums/ErrorCodeConstants.java(65 个都在这里,别新建文件)。
加一条架构禁令
- 在
AgentArchitectureTest加一个@Test,用findViolations(片段)或自己写扫描。 - 🔴 必须同时加一条反向自检(「那段东西必须还在 / 那个目录必须扫得到」)。 少了它,「把要守的代码删掉,禁令就更绿」这个共通失效方式就会命中你这条。
- 失败消息里写清正确做法,不只是「不许这样」。现有 5 条禁令的失败消息都带 「正确做法:……」段落和可照抄的类名——那不是啰嗦,是让撞红的人当场有出路。
- 若要开白名单,钉处数,不钉「这个文件豁免」。
加一个配置项
- 优先加进现有的 8 个
@ConfigurationProperties之一(前缀enlev.agent.*)。 - 🔴 不要用
@Scheduled(fixedDelayString = "${...}")里的裸 SpEL—— 那会变成第 9 个没有配置类、前缀还不统一的口子(现有两个调度器就是这个形态)。 - 每个字段都要写 javadoc 说明为什么是这个数。现有配置类的注释密度是全模块最高的, 那不是文风,是因为这些数是运营参数:现场要调的时候,调的人得知道调大了会怎样。
坑与注意
按失效方向排:先是静默 / fail-open 的(更危险),再是 fail-closed 的(至少会报错)。
🔴
enlev.agent.doc的文档同步:代码侧的空转已修、配置侧的错配也已修;而它曾经会删库,这条已在代码里堵死。 分五段说:失效方向变了三次,最后一段才是「配置侧也修好了」。- 老实现(M1 之前):标记指到
ainb-ui-admin-vue3/docs,靠向上探测找语料根。那个目录压根不存在,resolveRoot找不到只往warnings里加一句话、返回 null,planSync返回空列表、doSync不抛异常 ⟹ 一行文档都不灌且界面看不出坏了。失效方向:静默。 - M1(
1b1ac7b1e9)修好过:root-path变成必填显式配置项,未配直接抛AGENT_DOC_ROOT_PATH_MISSING、不是目录抛AGENT_DOC_ROOT_PATH_INVALID;当时 local 配的是C:/code/xpy-blog/docs、dev/prod 放注释掉的模板(即「不配就报错」),实测灌进 115 篇。失效方向:报错,这是对的。 - 🔴
c3dccc1a82 refactor(系统):区分专业版和开源版曾把它改回去(✅ 已修,见第五段):三个 profile 全部变成root-path: /,并且删掉了那段解释「为什么必填、空转长什么样」的 🔴 注释(git log -L可复核)。/能过Files.isDirectory校验(Windows 下解析成盘根),但默认的directories = ["guide","develop"]在它下面不存在 ⟹ 每个子目录各记一条「子目录不存在,已跳过」warning、语料扫出 0 篇、接口返回 200。
而这不只是「灌不进去」:
planSync的 REMOVED 差集是「库里有、这次没扫到」(:182-196),doSync对 REMOVED 一律deleteDocumentByName连带清掉段落与向量(:302-311)。差集的右项一旦因为root-path配错而变成空集,左项就会整个变成待删清单——在那个状态下跑一次doc-sync,会把已经灌好的 115 篇全部删掉,而向量库的写入不在事务里,删掉补不回来。- ✅
4093e19c7d(2026-10-25)把这条路堵死了:doSync开头加了硬守卫(:238-253),扫到 0 篇即中止整个同步,连知识库都不建,并把「拦下了几篇的删除」写进DocSyncResult#failures。失效方向从破坏性变成报错——这是对的,因为「同步完成、0 篇」与「跑成功了、只是恰好没文档」在界面上无法区分,本平台头号缺陷就是这个形状。 配套加了DocSyncPlan#skippedDirectoryCount(结构化计数,不是从warnings的自由文本里做字符串匹配——那是给人看的措辞,改一次文案就把守卫改没了)。 🔴 但「只缺一部分子目录」时刻意不拦删除(:255-263,只把「本次扫描不完整」写进 failures):拦了就等于「真的删掉整个目录」永远传播不到库里,僵尸文档那个缺陷会原地复活,而它是慢慢累积的、比误删更难发现。 - ✅ 配置侧也已修(2026-10-25):三个 profile 的
root-path改成${AGENT_DOC_ROOT_PATH:...}——local的默认值是本机手册仓库位置,dev/prod默认留空,没设环境变量就在第一次调用同步接口时抛AGENT_DOC_ROOT_PATH_MISSING,而不是像/那样静默扫出 0 篇。写法与同批整改里的DRUID_STAT_VIEW_*一致:默认值站在安全的那一侧,要放开必须显式给环境变量。base-url三个 profile 都是https://doc.enlev.cn,它与前端引用白名单(ainb-ui-admin-vue3/src/utils/url.ts的MANUAL_DOC_HOSTS)是同一个事实的两份拷贝——那道白名单刻意不从后端读配置(否则「后端或库被污染时前端不跟着放开」这道防线就没了),所以改一边必须改另一边。
所以现在的拦阻有两层:代码里的 0 篇硬守卫,加上
doc-sync-plan会如实显示removedCount与那两条 warning。先看 plan 再 sync 仍然要做——它拦得住「误删一部分」,代码只拦得住「全删」。directories默认guide,develop、exclude-patterns排掉develop/04-upstream/**,这两项没被那次重构动过。🔴 但「配置修好」不等于「引用链接可用」:线上托管端把 cleanUrls 的回落写成了 SPA 式
/index.html,对任意路径都返回 200 + 同一份首页,实测知识库要引用的 125 条 url 里 122 条落在首页(只有/guide/、/develop/这类目录索引页因命中$uri/而幸免);而且线上产物比语料旧(还有05-practice,没有06-practice与05-mobile)。原本要三件都到位:改 nginx 回落、重新构建上传dist/、再跑一次doc-sync。 ✅ 第三件已于 2026-10-25 做完,且已全绿:库里现在是 125 篇、686 段、686 段全部向量化,doc-sync-verify8/8 全OK、allHit: true、最高相似度 0.77,每条都带documentTitle与documentUrl;文档仓gen-kb-url-map.mjs对账「语料 125 / 库里 125 / 对上 125」,集合、路径、主机三项全过——存进去的 url 本身是对的。 中间还摔了一跤:第一次灌到一半撞上qwen3.7-text-embedding这单个模型的 DashScope 免费额度用尽(403 AllocationQuota.FreeTierOnly,684 段只有 472 段拿到向量),换到text-embedding-v4重灌才通。迁移脚本ainb/sql/mysql/update/2026-10-25-knowledge-embedding-model.sql;换模型的正确顺序与「向量文件按 Java 类名而非模型名命名、所有 TongYi 向量模型共用一个 json」这个坑,详见本页「未证实」节那条已转为实测的记录。 于是只剩前两件服务器侧的事了;它们做完之后引用链接自动可点,不需要再灌一次库。 验证一律用文档仓的pnpm check:live(比对渲染出的<h1>,不看状态码——那台机器上什么都是 200)。详见构建与部署。- 老实现(M1 之前):标记指到
hash 口径与「向量化失败不自愈」。 老实现把
syncedHashes放进程内 Map、重启即失效(重启后全部当 ADDED、重花一遍 embedding 钱);M1 改成从库里content现算(口径 = sanitize 之后),重启不再重灌。但 hash 只回答「正文要不要重灌」,而 embedding 失败不改变正文 ⟹ 重跑一律 UNCHANGED、整篇跳过,向量化失败的那些段落永远补不上、且所有计数都是绿的。N12 因此加了独立于 hash 的第二判据listUnembeddedDocumentNames(段落数 ≠ 已向量化数,含段落数为 0 的,0==0会被「相等」漏掉)→ 新状态 REEMBED(处置同 CHANGED,先删后建)。失效方向:静默缺一块,已修,且 2026-10-25 实测真的命中过这条分支两次:① embedding 额度中途用尽留下 26 篇缺向量的文档(正文一字未改,hash 判据全 UNCHANGED),重跑planSync正好判出reembedCount26、其余计数全 0、unchangedCount99(99+26=125);② 换向量模型时把段落全删、只留文档行,planSync判出reembedCount124 +changedCount1 = 125、removedCount0 —— 「含段落数为 0」那半条判据也验到了(counts == null那一支),并且证明清段落不会误触发 REMOVED 差集。N12 这条独立于 hash 的第二判据在真向量库上有效。两个调度器的配置前缀是
agent.workflow.*,不是enlev.agent.*(见「核心机制」第六节)。 失效方向:静默——配错了不报错,间隔悄悄留在 5000ms。逐 chunk 审核有一条跨 chunk 的缝隙。
AgentAssistantServiceImpl:1317-1321逐字: 「注入串正好被切在分片边界上时(前一片『忽略以上』、后一片『指令…』), 单看哪一片都不命中模式,直播这一路就漏标;而落库那份走 buildMessage 审核的是完整 answer,会标出来。 ⟹ 用户当场看到的与刷新后看到的可能不一致(多一个前缀)。 要消掉这条缝只能扣住整段不发,那等于放弃流式输出,代价明显更大—— 故这里选『直播尽力标 + 落库权威标』,缝隙写在明处」。 失效方向:静默,且是明知并接受的取舍,不是缺陷。别去「修」它,除非你愿意放弃流式。计量写入失败只记 ERROR,不抛。
AgentUsageLogServiceImpl:101-107逐字: 「🔴 计量失败绝不能让业务调用失败:用户问了话、模型也答了, 结果因为写日志出错收到『系统异常』——那是本末倒置。 但也不能静默吞掉:写不进去意味着账单有空洞,所以打 ERROR 让它在监控里可见」。 失效方向:静默漏账(有日志,但要有人看)。webhook 的 IP 维度限流可被伪造头绕过。
AgentWebhookProperties:49-55逐字: 「🔴 这一层是『挡老实人的误配置』,不是『挡攻击者』:来源 IP 取自ServletUtils.getClientIP(),而它按 hutool 的默认头序先读X-Forwarded-For/X-Real-IP等客户端可控的请求头。在没有可信反向代理兜住这些头的部署里, 直连客户端改一个头就能换掉自己的『IP』。 真正承重的是下面那层 path 维度——这正是两层必须是 AND 而不是 OR 的原因」。 失效方向:fail-open(IP 层可绕,path 层不可绕)。catch (Exception)拦不住StackOverflowError。 无界递归处置嵌套 map/list 会在图执行线程里抛Error而不是Exception, 执行层所有catch (Exception)一个都拦不住。唯一的防线是AgentSanitizeProperties.maxDepth=16(不是精细度参数,是「别把线程搞死」参数)。 失效方向:线程死。调大这个值之前先想清楚默认栈能撑几千层这句话。保留期的两处口径不一致。
AgentUsageProperties:37默认 180, 而AgentUsageLogRetentionJob:22-24的 javadoc 写「默认 0 表示不清理。 也就是说这个任务挂上去之后,在有人显式配置保留期之前一行都不会动」。 两句都对,但说的不是同一件事:job 读的是每行自己的retention_days列 (写入时从配置快照),2026-09-23 之前写入的行确实是 0,之后写入的是 180。 job 的 javadoc 描述的是那个旧世界,已经过时。 失效方向:文档骗人(机制是对的)。代码注释里引用的
xpy-*.sql一个都不存在。实测: 后端 11 处引用 / 8 个文件名,前端 10 处引用 / 8 个文件名, 去重后 12 个文件名,sql/mysql/里一个都没有——那里只有 6 个文件 (ai-2026-04-16.sql、ainb-init.sql、bpm-2026-04-18.sql、im-2026-06-20.sql、quartz.sql、ruoyi-vue-pro.sql),无一以xpy-开头。 被引用最多的一个是xpy-2026-09-27-agent-fine-grained-perms.sql(5 处)。 ⟹ 注释里的结论多数仍然正确(例如resumeRoute.ts说的菜单路径 「AI 助手 → 流程编排」=/ai/agent/workflow,我用ainb-init.sql的system_menu独立验过,是对的), 但出处已经全部失效。失效方向:静默——按图索骥的人会以为文件被删了。 新写的注释不要再引xpy-*.sql,直接引ainb-init.sql。架构测试的三处外部行号引用已漂移(见「核心机制」第五节的警告框)。 失效方向:静默,禁令本身不受影响。
framework/会碰数据库(10 个文件 importdal)。 给它写单测时不能假设无依赖。失效方向:测试写不出来(不是运行期问题)。listener/目录名骗人:只有 1 个文件,模块里有 2 个监听器。 失效方向:静默漏找。
以下 8 条是 fail-closed——踩了会当场报错或当场拒绝,相对好查,但都值得先知道:
- 拿不到身份就罢工。
AgentExecutionContext.isComplete():140-142要求 userId、tenantId、loginUser 三者都非空;AgentSecurityGateway.checkIdentity缺任一个就抛。 这与下游横切能力语义相反(行权限与字段权限在拿不到 userId 时是放行的)。 别指望下游兜底。 - 跨租户状态直接拒(
AGENT_CROSS_TENANT_DENIED)。 理由是「校验会失效」不是「不方便」:skipPermissionCheck()会把功能权限与数据权限一起短路。 - 查不到
agent_config行 = 未启用(AGENT_DISABLED)。 「默认关着」是数据层面的事实,不靠默认值写对。 - 工具权限是 AND 语义:声明的每个权限串都得有,缺一个就抛
AGENT_PERMISSION_DENIED。 agent_tool_def登记时不得与代码工具重名(AGENT_TOOL_DEF_KEY_CONFLICT_CODE_TOOL)。validateForSave:179-183的理由:「registry-first 下代码工具永远赢, 用户工具即便登记成功也调不到。与其让它『配了不生效且无提示』(平台头号缺陷), 不如登记时就拦下」。- 风险级为 null 一律回落
HIGH(WorkflowDslValidator:5279-5280、WorkflowDslPlannerPrompt:792、RiskLevel.parseOrHigh)。 AgentWebhookRateLimiter在 Redis 异常时 fail-close(tryAcquire:126-134)。 理由是一致性:「同一个匿名面上的两道闸,一道 fail-close、一道 fail-open, 等于把整条链路降级到 fail-open 那一道的水平」。 要「Redis 故障时仍通行」是运营决策,走rate-limit-enabled=false,不走静默降级。- 限流器取不到 IP 时落进
unknown这一个桶(:60-68), 「不是『跳过限流』的意思……于是这一层退化成一道全局闸。 宁可误伤也不留空档——匿名面上『识别不出来源』恰恰是最该被拦的情形」。
→ 本节是贴着上下文的提醒;按症状查的全域清单见坑位索引。
测试
🔴 这个模块跑不了 DB 测试
enlev-module-agent/src/test/resources/ 下只有一个 dsl-replay/ 目录(2 个 JSON fixture), 没有 application-unit-test.yaml。而基类 BaseDbUnitTest 是这样声明的:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE, classes = BaseDbUnitTest.Application.class)
@ActiveProfiles("unit-test") // 设置使用 application-unit-test 配置文件
@Sql(scripts = "/sql/clean.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
public class BaseDbUnitTest {三样东西缺一不可:application-unit-test.yaml(H2 数据源)、/sql/create_tables.sql、/sql/clean.sql。 agent 模块一样都没有。
全仓 5 个业务模块里,只有 3 个有这份配置:
| 模块 | application-unit-test.yaml |
|---|---|
enlev-module-bpm | ✅ |
enlev-module-infra | ✅ |
enlev-module-system | ✅ |
enlev-module-agent | ❌ |
enlev-module-ai | ❌(连 src/test/resources/ 目录都没有) |
⚠️ 这张表曾经有第 7 行 enlev-module-im ✅。那个模块已在 12347efcd1 里连目录一起删掉(ls -d enlev-module-* 只剩 5 个),所以「6 个模块 / 4 个有配置」的旧说法两处都过期了。详见后端架构与模块依赖。
⟹ 2,225 个 @Test 绝大多数是纯 Mockito,没有一个碰真库。 要在 agent 模块加 DB 测试,得先补齐这三份资源,别以为加个 @ExtendWith 就行。 :dsl-replay/ 那两个 fixture 是给 DSL 重放测试读的普通文件,与 H2 无关。
ai 模块同样跑不了 DB 测试
这一点在 ai 域开发 里会更严重:那边有 86 个 @Disabled 分布在 32 个文件里(29 个 *Tests.java 占 81 个,全都禁用),真正会跑的只有 10 个 *Test.java。 改 ai 域没有单测网兜底,改 bpm 有(bpm 的 src/test/resources 齐备、194 个 @Test 只有 5 个 @Disabled)。
架构测试怎么跑
AgentArchitectureTest 是纯文本扫描,不依赖 Spring 上下文、不依赖数据库, 但它依赖工作目录:SOURCE_ROOT = Paths.get("src", "main", "java") 是相对路径。
cd ainb && mvn -pl enlev-module-agent test -Dtest=AgentArchitectureTest必须在 enlev-module-agent 目录(或让 surefire 以它为 basedir)下跑, 否则 Files.walk 什么都不返回。这正是 testScannerActuallyCoversSources:305-315 存在的理由:
assertTrue(files.size() >= 2,
"扫描到的源文件只有 " + files.size() + " 个,工作目录或路径大概率不对,"
+ "这会让上面两条禁令变成永远通过的空测试");
// 反向自检:诊断端点里一定有 "ContextSnapshot" 这个词,扫得到才说明扫描逻辑有效
assertTrue(!findViolations("ContextSnapshot").isEmpty(),
"扫描逻辑失效:连已知存在的标识符都找不到");撞红了怎么读
5 条禁令的失败消息都是多行文本块,结构统一: 「为什么禁 → 正确做法(带可照抄的类名)→ 违规位置(文件:行号 原文)」。 先看「正确做法」再看「违规位置」,因为多数违规是「照着隔壁模块抄」抄来的, 而失败消息里那句「照 WorkflowScheduleTriggerScheduler 抄」就是给你的出路。
未证实
读源码读到、可以直接引用的:
pom.xml:15-24、:27-33、:71-75、:82-84的四段注释(模块独立理由、spring-ai 钉版、graph-core 锁版、GraalJS 显式声明)——逐字读过。AgentExecutionContext全 213 行、AgentSecurityGateway全 176 行、AgentArchitectureTest全 472 行、AgentWebhookRateLimiter全 136 行、AgentSanitizeProperties全 108 行、AgentDocProperties全 95 行、AgentDocSyncServiceImpl全 659 行、AgentDiagnosticController:33-80、AgentToolRegistry:120-279、AgentToolOverrideServiceImpl:1-120、AgentUsageLogServiceImpl:42-116、AgentAssistantServiceImpl:1264-1353——都是逐字读的。 ⚠️ 前五个「全 N 行」2026-10-25 复核未变;AgentDocProperties(86 → 95)与AgentDocSyncServiceImpl(269 → 619 → 659)都因1b1ac7b1e9与4093e19c7d长了一截,本页上一版的数字已订正。 后面那几个行号区间是初版阅读时的位置,之后没有逐个复核,引用前请现查。- 规模数字(298/61041、161/2225/62840、15 Controller、67 错误码、16 表 ↔ 16 DO、8 个
@ConfigurationProperties、3 job)——全部本次重新数过。 - 「agent 是严格的叶子模块」——
grep -rln "import cn\.enlev\.ainb\.module\.agent"排除 agent 自身后命中 0; 引用该构件的pom.xml只有 3 处(自身、enlev-server、根 reactor)。 - 🔴 「生效值只用于展示、不用于判定」这条结论需要重新审计,本页上一版的计数已全部作废。 上一版写「
.getRisk()7 处 /ToolDefinition#isRequireConfirm()4 处 / 生效值调用全在 4 个界面文件 /getDeclaredRisk()判定路径 7 处 + 展示 5 处」。2026-10-25 按同口径重数(grep -rn '\.getRisk()' --include=*.java限src/main/java,再逐条剔注释)得到的是:.getRisk()5 处、def.isRequireConfirm()2 处 (AgentGatewayDiagnosticController:84、AgentToolOverrideServiceImpl:331;AgentToolRegistry:289是ToolDefinition自己的实现体,不算调用方)、getDeclaredRisk()25 处。 更要紧的是**「全在界面文件」这半句已经不成立**:476b4b2637新增的framework/agent/core/tool/AgentToolCatalog.java在:225读def.getRisk()、在:137对生效值调isRequireConfirm(),而它位于framework/而不是界面层;service/workflow/AgentNameCatalogServiceImpl:128同样读生效值。 这两处是展示用途还是判定用途,本页没有读透,因此不再复述原结论—— 引用「生效值只用于展示」之前,请先自己把上面 5 + 2 处逐个核一遍所在文件的性质。 framework/有 10 个文件 importdal、listener/只有 1 个文件——实测。xpy-*.sql:后端 11 处 / 8 名,前端 10 处 / 8 名,去重 12 名,sql/mysql/6 个文件里 0 个匹配——实测。- 两个调度器前缀
agent.workflow.*、保留期 180 vs 0 的两处口径——实测。 AgentUsageLogRetentionJob里TenantIgnore恰好 2 处非注释命中(:4import +:49注解)——实测,与白名单钉的occurrences=2吻合。- 全仓只有 bpm / infra / system / im 四个模块有
application-unit-test.yaml,agent 与 ai 都没有——实测。 RiskLevel.parseOrHigh接String(:108)——实测;「宁可多说风险」这句口径在WorkflowChangeManifestBuilder:281-282与WorkflowDslValidator:5723-5724两处逐字读过。 ⚠️ 上一版把后者记成:5266,已随文件增长漂移。另外「内置工具那 7 处手写三元」是错的: 实测== null ? RiskLevel.HIGH :只有 3 处,且全在framework/agent/core/下 (manifest/WorkflowChangeManifestBuilder:283、plan/WorkflowDslPlannerPrompt:854、tool/AgentToolCatalog:224),tool/impl/的内置工具里一处都没有。
推理得出、未取证的部分:
- 「
WorkflowDslValidator的 34 条规则各自判什么」——号段口径已经查实 (权威在:5349-5441,即 V20validateChatLegsWriteReply自己的 javadoc;已按它规定的数法复算, 见 Plan 预览确认与 HITL · V 号段)。 号域本身是活的:本页初版写「V1~V33」(早期全文 grep 的产物)、上一版订正成 「V1~V16 + V18~V32,共 31 个号,下一个可用 V33」,本次实测已是 34 个号、下一个可用 V36 ——V33 / V34 / V35 是这两版之间补进去的。 但那 34 条的逐条判据本页没有读(文件 5800 行,只读过其中几处;上一版列的:690-696、:863、:939、:4903-4993、:5256-5293、:5266六个位置全部已漂移, 故不再列出,免得按图索骥读到别的规则上)。 - 「2,225 个
@Test绝大多数是纯 Mockito」——「绝大多数」是从 「没有application-unit-test.yaml⟹ 没有BaseDbUnitTest子类」推出来的, 我没有逐个测试文件确认它们的基类。 - 「删掉 spring-ai-bom 会
NoSuchMethodError」——这是pom.xml:31的断言, 我没有真的删掉跑一次。注释说 P0 spike 验过 1.1.8 上正常,但没验过 1.1.2 上会炸。 - 「
AgentDocSyncServiceImpl灌库会白花 embedding 费用」——这条推断的前提已经不存在了, 本页上一版还把它挂在「未证实」里。当时依据的是「syncedHashes放进程内 Map ⟹ 重启后全判 ADDED ⟹ 重灌一遍」;1b1ac7b1e9之后已无此字段,hash 改成每次ragApi.getDocumentContentHashes(knowledgeId)从库里现算(AgentDocSyncServiceImpl:157), 重启不再重判 ADDED,机制详见本页「坑与注意」的 M1 / N12 段。 ✅ 2026-10-25 真跑通了(此前三个 profile 的enlev.agent.doc.root-path都配成/,planSync对guide/develop各记一条「子目录不存在,已跳过」后扫出 0 篇(:141-148), 四条分支一次都没走过;配置已改成${AGENT_DOC_ROOT_PATH:...},local默认指到本机手册仓库)。 实测数字与事前用doc-sync-plan核对的预期逐字对上:warnings0 条、扫到 129 篇、 ADDED 14(9 页移动端 +tasks.md+ 改名后的 4 页06-practice)、CHANGED 36、UNCHANGED 75、 REMOVED 4(05-practice那 4 篇),库里 115 → 125 篇,耗时 5.4 分钟。 事后文档仓gen-kb-url-map.mjs对账「语料 125 / 库里 125 / 对上 125」,集合、路径、主机三项全过 —— 存进ai_knowledge_document.url的引用地址是对的(换完向量模型重灌后再跑一次,仍是 125/125/125)。 🔴 第一次没灌完,卡在一个代码之外的地方:684 段里只有 472 段拿到了向量,doc-sync-verify8 条问句全ERROR(查询侧的 embedding 同样失败,所以召回压根没跑起来, 不是「召回不到」)。原因是403 AllocationQuota.FreeTierOnly(包成NonTransientAiException)。 这个失败的形状值得记住:doSync照样返回 HTTP 200 且failures: []。 切分与向量化是@Async的,失败不会回传给同步结果;唯一的信号是doc-sync-status里embeddedSegmentCount(472) 与segmentCount(684) 的差额(该字段的 javadoc 写明了这层含义, 但没有任何断言替你盯着它)。别把 200 +failures: []当成灌完了。 另外那次POST /doc-sync跑了 325 秒,比 undici 默认的 5 分钟 headers 超时只差一点—— 客户端会先挂断(UND_ERR_HEADERS_TIMEOUT)而服务端继续在跑,此时不要重复点击, 改用doc-sync-status看进度(doSync没有并发锁,lastStatus是volatile而非互斥)。 ✅ 已解决,而且不需要给账号充值:那句报错里的「免费额度」是按模型计的,不是按账号计的。 内置知识库被钉在qwen3.7-text-embedding(ai_modelid 65)上,是那一个模型的额度用尽了; 同一把ai_api_key下另有 8 个启用中的向量模型(text-embedding-v4、qwen3.7-text-embedding-flash、qwen3-vl-embedding…)。把知识库换到text-embedding-v4即可。 🔴 但换向量模型不是改一行配置就完事:AiModelFactoryImpl:775的向量文件路径是{userHome}/vector_store/simple_{embeddingModel.getClass().getSimpleName()}.json—— 用的是 Java 类名而不是模型名,所以所有 TongYi 向量模型共用同一个simple_DashScopeEmbeddingModel.json。直接换模型会把两个维度/语义空间不同的向量混进一个文件, 而且两个VectorStore实例各自的Timer每分钟都在覆写它(日志里Overwriting existing vector store file就是)。正确顺序是: 停后端(止住 Timer)→ 挪走/删掉那个 json →DELETE FROM ai_knowledge_segment WHERE knowledge_id=7(只删段落、留文档行,正好把每篇判成 REEMBED)→ 改ai_knowledge的模型 → 重启 (确保进程里只有一个VectorStore实例)→doc-sync-plan核对 →doc-sync→ 轮询到embeddedSegmentCount == segmentCount→doc-sync-verify。 ✅ 换完实测:doc-sync-plan给reembedCount124 +changedCount1 = 125、removedCount0(那 1 篇 CHANGED 是本页——同步之后我又改过正文,hash 确实变了);doc-sync57 秒跑完,125 篇 / 686 段 / 686 段全部向量化,向量文件 11.43 MB;doc-sync-verify8/8 全OK、allHit: true、最高相似度 0.77, 且每条都带documentTitle与documentUrl(https://doc.enlev.cn/...)—— 其中「怎么新增一个业务模块(前后端)」命中的正是本轮新写的 任务索引,说明新语料真的进去了。 这条迁移已固化成ainb/sql/mysql/update/2026-10-25-knowledge-embedding-model.sql(按模型名关联取 id 而非写死 102,因为ai_model的自增 id 因环境而异;DELETE以「知识库当前还挂在旧模型上」为条件,所以可重复执行——实测二次跑DELETE/UPDATE均 0 行、686 段完好)。 🔴 为什么非固化不可:全量 dumpainb-professional-init.sql:8163里ai_knowledge那行 写死的还是65, 'qwen3.7-text-embedding',并且带着 115 条旧ai_knowledge_document与 584 条ai_knowledge_segment(那些vector_id指向的是旧向量文件里的向量)。 重新导一次 dump 就会把本节的修复全部退回,而且是退回到最坏的那种状态: 段落行声称已向量化、向量文件里却没有对应向量,doc-sync会因 hash 未变而判 UNCHANGED 跳过, 于是知识库「看着是满的、召回全空」。 ✅4093e19c7d加的「扫到 0 篇即中止」守卫此前只有两条AgentDocSyncServiceImplTest单测覆盖(Mockito 桩,不是真的向量库),现在真实删除路径也实测走过了 (第一次 sync 删掉05-practice那 4 篇时,向量文件从 9.7 MB 掉到 7.81 MB)。 - 「4 个诊断 Controller 不参与任何业务链路」——是从它们的路径前缀与
@Tag名称推断的, 没有反向查过「有没有别的类注入它们」。 - 「
framework/那 10 处 importdal都是合理的」——我只逐个确认了AgentSecurityGateway(注入AgentConfigMapper查总开关)与WorkflowDslValidator(查执行史决定拦还是降档)两处的用途, 其余 8 处只确认了 import 存在、没有逐一读它们拿 Mapper 干什么。
相关页面
- 工具体系 ——
@AgentTool注解、注册表、执行器、在线覆盖 - ReAct 循环与流式对话 —— 四条路由与九个事件类型
- 工作流引擎 —— DSL 编译、节点执行器、两个调度器
- Plan 与人工确认 —— 变更清单、预演、发布闸门、HITL
- 安全与提示注入 —— 五道污点源处置、匿名面、审核边界
- 计量与配额 —— 计费表与 trace 表为什么是两条互不相交的写入路径
- 前端流式(SSE) —— 两套事件词表的字段级对照
- 后端架构与模块依赖 —— 全仓模块划分
- 权限体系总览 —— 三层权限与本页反复引用的 fail-open 语义
- 测试与质量 —— 全仓测试口径
