> 系列:《VeapAI 自然语言操作实战》第 3 篇(核心篇)
> 相关源码:`veap-cloud/veap-modules/veap-ai/src/main/java/com/veap/ai/semanticTemplate/`(语义模板执行与渲染)、`veap-cloud/veap-modules/veap-job/`(定时任务)、`veap-cloud/DB/veap.sql`(知识主题、知识条目、上下文资源等表)
## 一、模型的第三个盲区:业务黑话
前面讲过表语义和接口语义。这两层补齐之后,模型知道该查哪张表、该调哪个接口、该用哪些字段。但它还是会在一个地方翻车:**值**。
用户说「查一下状态为启用的用户」,模型很自然地会去查 `status = '启用'` 或者 `status = 'enabled'`。而系统里存的是 `1`。
这不是模型笨。它没有别的信息渠道,只能按照训练时见过的模式猜。
按理说这个也能靠提示词解决——在提示词里把这个字段的字典项列出来就行。问题是字典项有多少:用户状态、角色类型、流程状态、单据类型、审核结果……一张表几个,几十张表就是几百条。全塞进提示词,token 成本先不说,模型对中间部分的注意力也会衰减。
我们最后选的做法是:**把这些术语关系提前固化成一份可挂载的资源,离线产出,运行期只读。**
## 二、三类资源,三个知识主题
需要提前固定的东西有三类,对应三个知识主题:
| 资源 | 知识主题编码 | 每行长什么样 |
| --- | --- | --- |
| 表目录 | `agent.globalCmd.catalog` | `表编码:veap_user` 、`表标题:用户` |
| 表关系 | `agent.globalCmd.relations` | `表编码:veap_user`、`表标题:用户`、`主键字段:id`、`标题字段:nick_name` |
| 字典翻译 | `agent.globalCmd.dictMap` | `字典:用户状态(sys_user_status)`、`选项:启用 → 1` |
三类资源各挂到不同节点上:
- 表目录与表关系挂给表定位节点。前者告诉模型「平台里有哪些表」,后者告诉它「这张表用什么做主键、用什么做标题」。有了这两条,像「删除编码为 temp01 的角色」这种命令,模型才能回传正确的编码字段与标题字段。
- 字典翻译挂给执行计划节点。它的唯一用途就是值翻译——把「启用」翻译成 `1`,把「停用」翻译成 `0`。提示词里写得很死:状态类字段的条件值必须按这份清单翻译成存储值。
三份资源的内容都是纯文本,一行一条,没有嵌套结构。这是刻意的:模型对扁平行文本的引用准确率,明显好过对深层 JSON 的引用。而且扁平行便于人工评审——打开资源内容,从上到下扫一遍,谁的标题没写清楚一眼能看出来。
三个知识主题在平台上长这样:
![知识主题列表:三个平台级主题]
## 三、三步构建链:全部用既有能力,不写新代码
这三份资源不是手工维护的,它们由一条固定的三步链产出。整条链复用的是平台已有的语义模板能力,没有为它新增任何后端代码。
![语义资源三步构建链:模板任务 → 知识主题 → 上下文资源 → 节点挂载]
**第一步,生成模板。**
平台有「语义模板」这个能力:一条取数语句加一个渲染模板,逐行渲染出知识条目。我们为三类资源各配了一份模板,取数口径如下:
| 资源 | 取数口径 | 渲染模板 |
| --- | --- | --- |
| 表目录 | `veap_dict_table`,`status=1`、`del_state=0`,按 `sort` 排序 | `表编码:{{tableCode}}` 换行 `表标题:{{title}}` |
| 表关系 | 同上,外加主键字段非空 | 表目录基础上再加 `主键字段:{{pkField}}`、`标题字段:{{titleField}}` |
| 字典翻译 | `veap_dict_option` 左连接 `veap_dict_type`,选项有效且存储值非空 | `字典:{{dictTitle}}({{dictCode}})` 换行 `选项:{{optionLabel}} → {{optionValue}}` |
三个语义模板的编码分别是 `globalCmdCatalog`、`globalCmdRelations`、`globalCmdDictMap`,与知识主题的编码配成一对。
三份语义模板在平台上的配置是这样的:
![语义模板任务:取数语句与渲染模板]
顺便说一句取数语句的四个稳定字段。语义模板执行时会自动带上四个字段:业务主键、来源类型、来源信息、标题。前三个用来做条目的幂等定位——**同一张表反复执行不会产生重复条目**,因为业务主键是唯一锚点。这个设计比「先清空再插入」稳得多,中途失败也不会留下半份残数据。
**第二步,执行模板。**
每个模板执行一次,流程是:按取数语句拿数据 → 逐行渲染模板 → 写入知识条目。执行过程全程留痕,落在 `ai_semantic_transform_log` 表里。
入口有两个,人工和定时都覆盖:
```text
POST /ai/semanticTemplate/execute 请求体 {"templateId":"<模板主键>","maxRows":0}
定时任务 aiSemanticTemplateExecuteByIdTask invokeTarget = execute(模板主键)
```
`maxRows` 传 0 表示不限制行数——表目录这类资源必须全量,少一行就意味着那张表的命令会被判定为「未定位到表」。
**第三步,同步资源。**
知识条目写在 `ai_knowledge_entry` 里,但那不是链路能读到的位置。链路读到的是上下文资源 `ai_app_context_resource`。中间这一步由主题同步完成:
```text
同步动作:知识主题 → 上下文资源
唯一写入点:AiAppContextResourceKnowledgeSyncAppService
入口:内网 POST /ai/inner/appContextResource/syncAllKnowledgeTopics
或定时任务 aiAppContextResourceKnowledgeSyncTask
映射规则:resource.res_key = topic.code
resource.content_text = 该主题下条目正文按 sort 合并
resource.source_session_id = String(topicId)
```
同步结果落在这张表上,字段不长,但有两个细节值得看:
| 字段 | 注释原文 | 细节 |
| --- | --- | --- |
| `res_key` | 资源key(与 AiConsole 上下文 key 语义一致) | 取值就是知识主题编码,两处编码体系合一 |
| `content_text` | 资源内容(文本/JSON 字符串) | 条目按 `sort` 合并成的整段文本 |
| `source_session_id` | 来源 sessionId(可选,便于追溯) | 这里存的是主题主键的字符串形式,用于反查来源 |
| `owner_user_id` + `res_key` | 唯一索引 `uk_owner_key` | 同一份资源不会重复 |
知识主题那张表 `ai_knowledge_topic` 上,与这条链相关的字段有五个:
- `code`(主题编码):资源键的来源;
- `title`(主题标题):注入提示词后成为资源小节的标题;
- `semantic_template_id`(语义模版主键):指向产出内容的模板;
- `semantic_status`(语义转换状态):标识转换状态;
- `parent_id`(上级主题主键):主题可多层,本次三类资源都是平铺的。
**第四步,挂到节点上。**
最后一步在画布上完成:把资源挂到对应的大模型节点上。
表定位节点挂两份(表目录、表关系),执行计划节点挂一份(字典翻译)。节点配置里的资源引用与提示词里的占位符必须成对出现——挂了资源但提示词里没有占位符,后端会在调用前直接报可读错误,不会静默忽略。
注入后的提示词长这样,资源标题自动成为小节标题:
```text
【全局命令-表目录】
表编码:veap_user
表标题:用户
表编码:veap_role
表标题:角色
【全局命令-表关系】
表编码:veap_user
表标题:用户
主键字段:id
标题字段:nick_name
```
## 四、和 Milvus 那条线怎么接
到这里可能会有人问:这套东西和向量库有什么关系?
关系在知识主题这张表上。同一个 `ai_knowledge_topic` 既能产出上下文资源供给大模型节点,也是向量检索的检索对象。平台里那套「文档 → 知识库 → 向量化 → 问答溯源」的知识库链路,入口就是知识主题。
平台对这两条路做了统一的映射层,让节点在挂载知识主题时不必关心背后是静态文本还是向量检索:
- 主题映射缓存:资源键到主题 id 的映射;
- 集合映射缓存:主题到 Milvus 集合与契约字段的映射;
- 水位缓存:缓存构建的时间戳,只增不减,用来判断缓存是否可用;
- 检索结果缓存:相同主题、相同问题、相同参数的结果缓存 10 分钟;
- 查询向量缓存:相同嵌入模型与相同内容的查询向量缓存 24 小时。
缓存构建的唯一写入点在服务端,应用启动时会预热,也提供了刷新入口,前端工具栏上有一个刷新缓存的按钮。
静态与动态这两条路的切换关系如下:
![向量与知识主题衔接:静态保底、动态增强、错误边界]
真正有意思的是**回退策略**。节点挂载知识主题时,如果这个主题没有对应的向量资源,就按静态资源注入;如果向量检索过程中 Milvus 不可用,就退回静态内容,记录一条警告日志,但命令继续执行。只有非可用性问题(比如契约就绪检查失败、嵌入模型报错、缓存损坏)才会中断。
判断哪些错误属于「可容错」,依据是错误码区间:Milvus 相关的 500xx 归入可容错,400xx 与其他一律抛出。这条边界写得很窄——**能降级的只有向量库不可用,不能降级的是配置错了**。
## 五、两个诚实的取舍
### 5.1 哪些数据可以静态化,哪些不行
我们一开始想把字段清单和接口清单也一起静态化,省掉运行期的两次接口调用。想了一天,放弃了。
原因有两个。第一,字段清单和接口清单的输入是动态的——取决于命令解析出来的表编码,而表编码要到运行时才知道。第二,更要紧的:这两个清单要被脚本读取并逐字段断言。静态资源注入的是提示词文本,脚本读不到它,等于把强校验削弱了。
最后定下的判断标准是一句话:**只有「平台级静态事实 + 无脚本校验依赖」的数据才静态化,其余一律运行期拉取。**
按这条标准过一遍,结论很清楚:表目录、表关系、字典翻译,三个都可以;字段清单、接口清单、业务数据,三个都不行。
这条判定后来成了团队约定,避免以后有人「顺手也静态化一下」,把校验削弱了还不知道。
### 5.2 离线化换来的代价
静态化的好处前面说了:运行期不再请求、内容可离线评审、结果可预期。代价也要如实讲:**资源有刷新延迟**。
表字典或字典项改了之后,需要重跑两步——执行语义模板、同步知识主题。两步都可以挂定时任务,但它们是定时的,不是实时的。也就是说,新加了一张表,可能要等下一轮任务跑完,AI 才能识别到它。
我们没做「运行期自动同步」。理由是运行期同步会把一次命令的耗时和一个不确定的批处理绑在一起,还可能因为并发执行导致资源内容互相覆盖。宁可让使用者知道「改了字典要等刷新」,也不要造一个看起来实时、实际不可控的通道。
顺带一句,知识主题这张表上还有审核与开放两个状态位。这两位的存在说明知识条目是可以先审后开放的——对静态资源链来说它们一般保持开放,但如果某个环境要用审核流程,这套结构是现成的。
## 六、这条链上的一个隐蔽坑
讲一个排查了很久的问题,关于缓存水位。
水位的作用是标记「这份缓存是哪一轮构建出来的」。判断逻辑很朴素:读出来的水位如果没有值,就认为缓存无效,需要重建。
上线后出现了奇怪的现象:缓存明明构建成功,下次读取却总说水位缺失,然后重建一次。反复重建,性能和日志都不好看。
根因在序列化上。全局的 JSON 序列化配置对长整型注册了一个转字符串的规则,写入时是数字,实际存进缓存的是带引号的字符串。读取时用「是不是数字类型」判断,判断失败,于是报「水位缺少构建标记」。
修法是在写入端显式转成字符串,读取端按纯数字文本解析,不依赖类型判断。同时把这条教训写进契约:**跨进程缓存的读写,统一走文本口径,不要依赖运行时类型。**
这类问题的价值在于它提醒我们:缓存这种东西,写入和读取两端必须共享同一份显式的格式约定,靠「类型推断」迟早出问题。
## 七、想自己搭一遍
顺序上建议这样:先把表字典和字典项维护好(因为三步链的输入就是它们),再建三个知识主题与三个语义模板,然后依次执行模板、同步主题、把资源挂到节点上。
判断成功的标准很直接:打开上下文资源列表,三份资源的 `res_key` 分别是那三个主题编码,内容分别是表目录、表关系、字典翻译三类文本。表目录那行里能看到 `表编码:veap_dict_table`,字典翻译那行里能看到 `选项:启用 → 1`。
![上下文资源内容:表目录 / 表关系 / 字典翻译三份文本]
然后跑一条带字典值的命令试试,比如「查询状态为启用的用户」。在参数面板里展开执行计划,如果过滤条件的值是 `1` 而不是「启用」,就说明第三层语义真的接上了。
换一篇聊聊另一件事:这套编排在性能上做了哪些取舍,以及运行出错之后该从哪里看起。