用户问“查 1 月各渠道净收入”,Agent 先要知道净收入怎么算。用户又说“以后默认用元”,系统需要保存一个人的展示偏好。若用户接着补充“退款分析按原因”,这句例外还要在下次相关问题里被找到。
这些内容都可以写成文字,但不能只按“相关程度”混在一起使用。净收入的公式影响共享指标,金额单位可以因人而异,退款分析的分组又有自己的适用范围。资料层要回答两个问题:这段话依据什么,以及它在这次任务里能起什么作用。
第一篇走完了从提问到查询结果的流程。本篇从其中的资料读取展开,以公开提交 6801df7为准。订单数据和业务说明均为合成材料,图中的流程与编号用于解释机制。
先固定这次分析看过的资料
“净收入”不能只从字段名猜出来。DDL,即建表语句,说明主键、字段类型和约束。ETL 是把上游数据加工到目标表的过程,加工 SQL 说明金额经过哪些过滤、汇总和关联。业务文档则解释为什么这样算,以及规则的边界。
合成订单例子恰好需要三者配合。DDL 表明一行由 (order_id, line_id) 唯一标识。加工 SQL 先汇总成功付款和退款,再连接订单行。业务说明规定,净收入按 paid_at 归属付款期间,默认排除测试交易。于是,2 月发生的退款可能减少 1 月净收入;查询已加工好的行金额,也不能再连接付款事件后重复累加。完整约定见业务说明。
这些资料会变化。假设后台开始分析时读到加工 SQL 第 3 版,模型返回时 SQL 已改为第 4 版,旧解释就不能直接覆盖当前语义。
预填任务因此保存一份固定输入。“预填”指根据现有资料补充语义条目。任务的 input_json 包含目标对象和来源材料,input_fingerprint 标识这份输入,expected_version 记录目标对象的起点版本。每份来源再带自己的 source_id、version、正文和 complete。冻结的是本次分析的输入,资料以后仍可继续改版。任务入库代码保存这些字段。
材料收集还会查相关文档和有限深度的上游。缺来源、正文未取得或材料超过长度上限时,输入中的 coverage.gaps 会留下具体原因。模型只能解释已经拿到的部分。材料收集与长度控制没有把截断标成完整读取。
当前后台预填做一次分析,再做一次依据复核。正式应用前,Rust 重查目标对象和所有来源版本;期间有人改了对象或资料,旧任务会失去适用条件。预填用例将这类结果记为 superseded,避免晚到建议覆盖新内容。
模型建议和人工值怎样共存
模型给出解释后,系统还需要区分“原文说了什么”“模型怎样理解”和“维护者最后采用什么”。知识对象按条目保存这些内容:
| 字段 | 保存的内容 | 使用时怎样理解 |
|---|---|---|
source_facts | 来源标识、版本、位置及原文事实 | 可以回到材料核对 |
suggestion | 模型建议、引用依据、缺口和分析状态 | 说明模型的解释与限制 |
human_override | 人工填写的值及修改记录 | 有人工值时优先保留 |
effective_value | 当前采用的条目正文 | 读取共享语义时使用 |
review_state | 未验证、已确认或待复核等状态 | 生效与核对状态分别表达 |
模型引用也经过结构检查。每条引用的 source_id 和版本必须出现在固定输入中,quote 必须能在该来源正文中找到。这个检查能发现不存在的引用,不能证明模型的解释与原文语义完全一致。代码里的 validated 特意写明“引用和格式已核对,业务含义尚未人工确认”。检查逻辑保留了这一区别。

机制示意。没有人工覆盖时,合法建议可以成为有效值;存在人工覆盖时,新建议保留供复核。两条路径都要先通过版本检查。
重新分析的核心判断很短。下面摘自实际的建议应用函数:
entry["source_facts"] = new["source_facts"].clone();
entry["suggestion"] = new["suggestion"].clone();
if entry["human_override"].is_null() {
entry["effective_value"] = new["effective_value"].clone();
entry["review_state"] = json!("unverified");
} else {
entry["review_state"] = json!("needs_review");
}
例如,维护者已补充“退款扣回原付款期间”,下一次模型建议却只写“付款减退款”。人工正文会保留,新建议另存,条目标为 needs_review。没有人工值的条目可以采用新建议,但状态仍为 unverified。系统允许有依据的预填先进入使用流程,并未要求所有条目逐一人工批准。
知识新版本、历史版本和索引更新任务在同一事务内保存。这保证正式内容保存后有对应的更新任务。它不保证向量索引已同步完成,所以“内容已保存”和“检索已更新”仍是两种状态。
检索分路,命中后回 MySQL
同一个问题可能包含表名、业务词和自然语言描述。精确表名适合直接定位;“扣掉退款后还有多少收入”则需要找到措辞不同的净收入定义。
共享知识检索会合并当前 MySQL 内容的词法候选、本地检索条目和向量候选。词法检索按文字匹配,向量检索按文本的语义接近程度找位置。排序时,完整名称或 ID 命中有明确优先级,其余候选融合词法和向量排名。结果数量允许时,还会给表、字段、指标、文档等不同类型留位置,避免字段条目挤掉指标定义。排序实现展示了这几步。
个人记忆走另一条候选路径,Skill 则只列出本会话已选的有效版本。它们最终可以出现在同一次工具返回里,却分别位于 personal_memories、selected_skills 等字段。正文的使用范围不会因为同屏返回而合并成共享定义。工具返回结构明确区分了这些对象。
索引中的文字可能已经过期,所以候选要“回源”:根据 ID 重新读取 MySQL 正式记录,再检查空间、启用状态、当前版本和来源依赖。当前实现会过滤带旧版本的索引候选;新版本可以通过当前内容的词法路径补入。交给 Pi 的正文来自核对后的正式对象。检索用例执行这一步。

机制示意。候选排序回答“先看谁”;回源检查回答“这份内容还能不能读”。图中的版本号为教学编号。
依赖检查会继续查看引用的来源。一个指标引用文档第 2 版,文档又依赖加工 SQL 第 3 版,那么只核对指标自身版本还不够。当前代码会沿文档来源继续检查;依赖过期会使读取失败,循环引用也不能互相证明有效。来源依赖检查负责这个判断。
长正文需要再用 read_knowledge 读取。工具按条目和 Unicode 字符位置分页,返回 offset、total_chars、complete、next_offset。后续页带上同一个 version;若对象期间改版,读取会报 stale_knowledge,不能把两版内容拼起来。原始 DDL、加工 SQL 等通过 read_source 补读。它要求来源快照本身完整,且仍是当前版本;返回给模型的一页可以只是片段,再沿 content_page 续读。分页与读取入口沿用相同原则。
检索本身有候选数量和索引覆盖边界。search_coverage 会报告这些限制;向量服务不可用时,仍可能沿词法路径查到对象。一次空结果只能说明本次没有找到,不能证明整座知识库不存在相关定义。
共享定义、个人偏好和本次条件分开
把“以后默认用元”存起来很有用。把“这次用分”也存成长期偏好,下次就会出错。相同的文字存储能力,需要不同的作用范围。
| 内容 | 保存位置 | 影响什么 |
|---|---|---|
| 净收入为累计付款减累计退款 | 共享语义 | 指标计算及业务边界 |
| 以后默认用元展示 | 个人记忆 | 本人未明确指定时的展示习惯 |
| 这次用分,只看 app | 当前任务条件 | 这一次分析的明确要求 |
| 每月按固定步骤检查净收入 | Skill | 用户选用的一套可复用方法 |
本次明确要求优先于个人默认。用户要求“这次用分”时,当前任务按分处理,长期偏好可以继续保持为元。若用户改变的是标准指标的过滤规则,还需要说明本次采用了自定义口径,不能悄悄改掉共享定义。设计中的内容分工解释了这个选择。
是否值得长期保存,仍由 Pi 理解用户意图。Rust 核对工具引用是否出自当前真实消息,并检查明确的临时或否定限定。它还会把逗号后同句的余下内容补入 source_text,避免只引用“以后按渠道”,却漏掉后面的例外。这些是有限的文字边界检查,不是一套完整的中文理解器。memory_source 的实现可以直接看到检查范围。
个人纠错也不会自动成为共享语义。影响公共定义时,可以另交修改建议。负责人接受建议后,还要核对、编辑并明确保存;仅接受不改变正式口径。原来的个人记忆和聊天并不因此公开。共享纠错流程保持了这条边界。
Mem0 给候选,MySQL 保存正式记忆
Mem0 是这里接入的开源记忆库。它负责从原始消息提取候选正文,并提供语义检索。Rust 与 MySQL 负责个人资产的正式正文、用户与空间归属、来源、版本、状态和依赖。
保存“以后默认用元”时,Pi 先提出带原文引用的 manage_personal_asset 操作。Rust 检查引用和运行身份,再把完整消息及相关引用交给 Mem0。完整消息用于保留上下文,引用用于指出这次要提取哪项可复用内容。提取产生的条目先处于 pending,尚不能作为正式记忆召回。Python 适配代码保存候选及操作回执。
Rust 拿到候选后再次检查,再保存正式资产。自动提取的 body 和 scope 使用同一份最终文字,name 只取前 40 个 Unicode 字符作为标签。这样可以避免正文保留了例外,另一次生成的范围却把例外丢掉。原话另存为 source_text;没有独立验证的记忆保持 verified=false。保存入口同时登记索引待办和工具回执。
随后,Worker 将已保存版本更新到 Milvus。Milvus 是向量检索索引,记录带有正式 asset_id 和 asset_version。人工页面已经写好的正文使用 infer=False 建索引,避免再让提取模型改写一次。检索只返回已就绪的候选标识,Rust 仍要回 MySQL 对照当前资产。索引提交与搜索体现了这层分工。
新运行准备上下文时,Rust 对候选按 ID 和版本回源。当前实现还会补入尚未命中索引的有效目录项,再限制初始条数;目录工具仍可分页。Mem0 搜索失败时会标明 directory_fallback,使用可读的正式目录,不把故障解释成“用户没有记忆”。运行准备代码负责这些行为。
Pi 的记忆参考按成功回执刷新
记忆进入模型时,还要避免旧偏好在每次请求里反复出现。当前实现使用 Pi 官方 context 扩展,在普通模型请求前插入一份本轮记忆参考。
这份参考在 Pi 中是临时 custom 消息,转换给模型时使用 user role。正文明确标注它是资料,不是当前用户指令或执行授权。它不再拼入 system,也不直接写入 Pi 的 JSONL 会话日志。JSONL 是逐行保存 JSON 记录的格式;这里的临时投影与持久会话历史分开。具体接入见会话装配和上下文接缝说明。
如果用户把默认单位改了,只有正式工具成功回执才能撤下本轮旧候选。下面是实际的回执处理:
acceptReceipt(receipt: DataToolOutcome) {
if (receipt.tool_name !== 'manage_personal_asset' || receipt.data.error) return;
const remaining = memories.filter(memory => memory.id !== receipt.data.id);
if (remaining.length === memories.length) return;
memories = remaining;
// 成功回执已包含新正文,不用模型提交的候选字段重建记忆,也不改变authority。
reference = {...reference, content: content()};
},
过滤按资产 ID 进行。修订后的正文沿成功工具结果进入会话;失败时,原候选仍保留。正常调用和中断后的半轮恢复,都通过 dataTools.invokeRecorded 接回回执,再调用同一个处理函数。恢复因此不需要另写一套“猜测这次是否改成功”的逻辑。恢复入口与工具回执入口连接了这条路径。

机制示意。临时参考不写入会话日志,已有工具结果和摘要仍属于历史。来源补读与候选刷新分别解决“读全限定”和“停止重复注入旧值”。
假设一条记忆的 source_text 保存了原话:“以后默认用元展示净收入,按渠道分组,退款分析按原因分组。”这是一条教学偏好。若提取后的短正文遗漏后半句,只读短正文就会把分组范围用错。
默认返回不会展开 source_text,而是提供 source_page 元信息。Pi 可以用 read_knowledge 指定 object_id=asset-<资产ID>、相同 version 和 entry_id=source_text 补读原话。下面是该例请求 offset=0, limit=18 后的裁剪返回,仅为教学示意,不是运行日志:
{
"id": "memory-demo",
"version": "2",
"source_text": "以后默认用元展示净收入,按渠道分组,",
"source_page": {
"offset": 0,
"total_chars": 28,
"complete": false,
"next_offset": 18
}
}
下一次沿 offset=18、同一版本继续,才能读到后半句“退款分析按原因分组。”。next_offset=null 表示没有后页;如果从中间开始读,complete 仍不会变成“这一页包含全文”。正文、范围和来源分别使用 content_page、scope_page、source_page。分页实现按字符计算这些位置。
读到“退款按原因”后,还要核对数据是否支持。当前合成资料没有退款原因字段,因此这条偏好不能凭空生成一列。来源补读让例外可见,是否理解正确、是否有数据可算,仍需分别判断。
context 扩展只负责投影资料。实际外发前,Rust 仍在模型调用的预留和发送入口检查运行、依赖和预算。新输入遇到旧依据失效时重建上下文;同一输入的中断恢复若已失效,则返回 stale_context,避免丢掉原调用身份后重新执行。恢复规则与临时投影各有职责。
停用记忆也不等于删除全部历史文字。旧工具结果、助手复述和原生压缩摘要仍可能保留。当前实现撤下本轮旧候选,并在后续采用时检查有效性;它没有承诺让模型已经读过的文字从所有历史中消失。
Skill 保存方法,选用后再读正文
Skill 适合保存重复使用的分析方法。例如“月度净收入检查”可以说明先确认月份,再读取指标定义、检查粒度和过滤,随后生成 SQL。公式继续引用共享语义,月份来自本次条件,金额展示可以沿用个人偏好。
用户必须明确选用 Skill。会话记录选中的资产 ID 和版本,版本变化后需要重新选择。当前空间的公共 Skill 也遵循这个规则;发布会创建独立公共副本,个人后续修改不会自动同步过去。Skill 的当前约定说明了选择与共享边界。
Pi 初始只拿到已选 Skill 的名称、适用范围和虚拟路径,如 /skills/<id>/<version>/SKILL.md。项目复用 Pi 的 Skill 目录解析,但没有把正文或附件提前复制到本地目录。资源适配只准备元信息。
模型需要正文时调用受控 read。Rust 根据路径解析资产和版本,核对归属、状态、会话选择及知识依赖,再返回正文或文本附件。Node.js 复用 Pi 的按行分页和输出格式,底层读取操作只接受这份已核对的返回,不能借路径读取主机文件。对应实现是 Rust 读取入口和 Pi read 适配。
因此,Skill 中即使写了“直接执行”,也不能替当前用户授权 SQL。它提供方法,具体动作仍经过数据工具与宿主校验。
本篇解释的是当前代码怎样保存依据、刷新候选和读取限定。已有记忆上下文回归使用真实 Pi 循环和确定性替身,覆盖角色、成功撤旧、不写日志和半轮恢复。这些检查不能证明真实模型每次都能提取完整例外,也不能证明小型合成目录上的检索效果适用于企业资料规模。
下一篇:Data Agent 工程实践(三):SQL 已经发出,系统怎样负责到底。我们继续追踪已经绑定定义与条件的查询,看看并发、超时和中断发生后,系统怎样保留原来的执行身份。