AIOT架构
月度归档: 2026 年 11 月
实现一个简单的长期记忆DEMO
实现一个简单的长期记忆DEMO
要理解智能体的长期记忆,可以先看两个问题:一轮对话结束后,系统怎样判断哪些信息值得留下;之后遇到相关问题时,又怎样把这些信息找回来。本文通过一个 Java Demo 展开这两个环节:回答完成后,模型提出记忆操作候选,由 Java 程序校验并保存;下一轮提问时,程序检索相关记忆,连同近期对话一起提供给模型。
一、长期记忆在 Demo 中保存什么
Demo 把长期记忆按用户归属,使用 userId 区分不同用户;同一用户的记忆可以在不同 threadId 会话中召回。短期对话消息仍按 userId + threadId 隔离。
long_term_memories ├─ id 记忆 ID ├─ user_id 记忆所属用户 ├─ memory_type semantic / episodic / procedural ├─ content 记忆正文 ├─ source_thread_id 来源会话 └─ updated_at 最近更新时间
三类记忆分别用于保存事实、经历、偏好或规则。这里的重点是长期记忆的管理流程;数据库保存完整记录,而本轮只把检索到的少量候选交给模型。
二、从问答到长期记忆变更:整体流程
关键边界:提取模型只返回候选 JSON,不直接连接数据库。Java 应用负责检查候选并执行存储操作;提取调用失败不会撤销已经生成并保存的本轮回答。
三、模型提出的操作 JSON
extractMemoryOperations() 把提取规则、近期用户发言和最多 100 条已有记忆交给模型,请它返回以下结构。Assistant 的回复不会被当作用户事实。
{
"operations": [
{
"action": "ADD",
"type": "procedural",
"content": "用户偏好简洁的中文回答,少用术语。",
"target_id": null,
"confidence": 0.96
}
]
}
| 字段 | 含义 | 示例或规则 |
|---|---|---|
action | 模型建议执行的动作 | ADD、UPDATE、DELETE、IGNORE |
type | 记忆类别 | semantic 事实、episodic 经历、procedural 偏好或规则 |
content | 精炼后的记忆正文 | 新增或更新后要保存的内容 |
target_id | 待更新或删除的已有记录 ID | ADD 通常为 null;UPDATE、DELETE 指向已有记录 |
confidence | 模型按提示给出的自评数值 | 当前程序忽略低于 0.85 的候选;不是校准概率 |
四、applyMemoryOperations():校验后再执行
该函数位于 LongTermMemoryDemo.java。它接收提取模型的 JSON、当前用户和线程信息、最新用户消息以及提取前的记忆快照,返回成功变更的条数。
- 读取 operations:如果缺少操作数组或结构不正确,则不修改记忆。
- 过滤:置信度低于 0.85 或动作为
IGNORE时直接跳过。 - 处理 DELETE:最新用户消息必须包含明确的遗忘意图;目标 ID 还必须存在于当前用户的记忆快照中。
- 验证内容:ADD 和 UPDATE 的记忆类别必须有效;正文长度为 4–400 个字符,且不能命中敏感信息检查。
- 执行 ADD:调用
upsertMemory()写入;数据库用用户、类别和正文的唯一约束避免重复。 - 执行 UPDATE:目标记录必须属于当前用户,且类别匹配,然后调用
updateMemory()。 - 执行 DELETE:调用
deleteMemory(userId, id);SQL 同时限定用户 ID 和记忆 ID。
五、长期记忆的查询与手动管理
自动召回
每次普通提问时,findRelevantMemories(userId, question, 5) 先从当前用户的记录中取候选,按关键词重合数量由高到低排序;同分时优先较新记录,最多取 5 条。命中的记忆加入 system 提示,近期对话则以各自的 role 加入请求消息。
用户命令
| 命令 | 行为 | 相关存储函数 |
|---|---|---|
/memories | 列出当前 userId 的全部长期记忆及来源线程。 | listMemories(userId) |
/forget <记忆ID> | 删除当前用户指定的记忆;不属于当前用户的 ID 不会删除。 | deleteMemory(userId, id) |
| 普通问题 | 自动检索相关记忆并将少量结果提供给模型。 | findRelevantMemories(...) |
六、Demo 和实际项目的差距
这个 Demo 把长期记忆的核心闭环放在一个 Java 程序里,便于看清每一步由谁负责。真正上线后,数据量、用户规模和可靠性要求都会提高,因此下面这些地方通常还需要进一步设计。
1. 记忆提取会增加一次模型调用
Demo 在每次成功问答后同步调用提取模型。这样流程直观,但用户需要多等一次模型响应,也会增加 API 成本;若提取接口故障,记忆本轮就没有更新。实际系统可以把提取工作放到后台队列异步执行,配置重试和失败告警,同时确保主问答不被拖慢。
2. 模型给出的 confidence 不能当作真实概率
这里的 confidence 是模型根据提示自行生成的分数,0.9 并不代表经过统计校准后有 90% 的正确率。Demo 用阈值做一层简单筛选;生产系统还应结合明确的规则校验、来源证据、冲突检测,必要时对高风险记忆要求用户确认,并通过评估数据检查误写和漏写。
3. 关键词检索的语义能力有限
当前实现把问题和记忆拆成词,再按重合数量排序,优点是逻辑简单、结果容易解释,也不需要额外的 Embedding 调用。但“别太啰嗦”和“回答简洁”可能用词不同却意思接近,关键词检索未必能匹配。记忆数量或问法变化增多时,可考虑全文检索与向量检索混合召回,再重排候选,并用检索评估集验证效果。
4. 自动写入需要完整的记忆生命周期管理
只把内容写进去还不够:重复记忆要合并,用户更正要处理旧值与新值的冲突,过时信息要能更新或过期,用户要求遗忘时也要确保真正删除。Demo 只用有限规则演示这些动作;实际项目通常还要记录来源和更新时间,制定保留策略,并提供查看、纠正、删除和审计能力。
5. 存储与权限要适应真实部署
SQLite 适合单机学习 Demo,数据量和并发上升后,生产服务一般会考虑集中式数据库、备份恢复、迁移和并发控制。每次读取、更新或删除都必须校验用户身份和数据归属;敏感信息还需要明确的保存范围、访问控制和删除策略,不能只依赖模型提示词来保护。
总结
通过这个 Demo,可以看到长期记忆并不是把所有旧对话一股脑交给模型。每次提问时,程序先从属于当前用户的记忆中找出少量相关内容,和近期对话一起提供给回答模型;回答完成后,再由提取模型判断用户这轮有没有表达值得以后保留的信息。
提取模型给出的只是操作建议。是否新增、更新或删除,最终由 Java 程序依据规则和用户归属进行检查,再写入 SQLite。这样,模型负责理解内容,应用负责把关并管理数据。
这版实现用关键词排序和简单校验展示了基本思路。真实项目还需要随着用户规模和使用要求,逐步完善记忆检索、异步提取、数据治理、权限与评估。理解这个基本闭环后,我们就有了继续改进长期记忆系统的起点。
实现一个简单的短期记忆DEMO
实现一个简单的短期记忆DEMO
短期记忆让应用能在同一段会话中接续上下文。实现的核心很直接:按会话标识保存消息,每轮请求时取出对应历史交给模型。但当会话越来越长,系统还需要决定带哪些内容、如何控制长度,以及怎样压缩较早的对话。
一、短期记忆在程序里是什么
短期记忆可以理解为一条会话线程的持续状态。应用用 threadId 标识会话,把用户消息和模型回复按先后顺序保存;下一轮再取回同一线程的历史,让模型获得这段对话的背景。
数据库记录的基本结构
messages 表保存每条消息的线程 ID、角色、正文和时间;按 thread_id 与消息顺序查询。不同线程的数据不会混在一起。
messages ├─ id 消息顺序 ├─ thread_id 会话标识 ├─ role user / assistant ├─ content 消息正文 └─ created_at 保存时间
Demo 还提供切换线程、列出线程、查看当前历史、清空当前线程等命令。模型调用成功后,应用在同一事务中保存本轮的用户问题和助手回答,避免只保存半轮对话。
本节 Demo 的代码位于 Gitee 仓库的 Page7 目录:https://gitee.com/toxplore/ai/tree/master/Page7。程序默认将数据库保存在 data/short-term-memory.db。
二、Demo 完整流程图
下面的流程图对应 Page7 中的 Java 程序:从启动配置和 SQLite 初始化,到每轮问答、命令处理,以及 thread 之间的隔离与恢复。
1. 启动:准备会话消息存储
图较宽时可横向滚动查看。
SQLite 在这个 Demo 中负责持久保存会话消息。模型不直接访问数据库;Java 应用负责按 threadId 读取和写入。
2. 普通问答:读取历史、请求模型、保存本轮
注意:Demo 在模型成功返回后才调用 saveTurn()。如果 API 报错,本轮用户问题不会写入 SQLite;此前成功的轮次仍保留。
3. Thread 命令:切换、查看或清空会话
/thread <会话ID>switchThread() 验证 ID,并读取历史条数;main 更新当前 ID。实际历史会在下一次普通提问时重新加载。
/memoryprintMemory() 调用 loadMessages(),打印当前 thread 的消息。
/threadsprintThreads() 调用 listThreads(),列出已有消息的 thread ID。
/resetclearThread(threadId) 只删除当前 thread 的消息。
/quitmain() 结束交互循环;SQLite 中已保存的会话仍保留。
4. 记忆隔离与持久化
三、会话变长后会遇到什么问题
| 问题 | 为什么会发生 | 可能的影响 | 解决方案 |
|---|---|---|---|
| 上下文窗口有限 | 每轮把全部历史重新发送,消息会不断累积。 | 请求可能超出模型限制;即使没超限,也会增加输入 token、成本和响应时间。 | 用模型对应的 tokenizer 估算 token;预算中先扣除系统提示、当前问题、工具定义、回答空间和余量。超出阈值时压缩较早消息,只发送“摘要 + 最近几轮原文 + 当前问题”。 |
| 历史噪声变多 | 已结束的话题、重复内容和临时细节仍在历史中。 | 模型可能被无关背景分散注意力。 | 围绕当前任务挑选上下文:保留任务目标、已确认决定、约束、待办和近期对话;不相关的旧话题不放进本轮请求,需要时再从完整历史查回。 |
| 简单截断会丢重点 | 只保留最近几轮,可能删掉更早但仍有效的决定、偏好或任务目标。 | 模型忘记约束,或重复询问已经确认的信息。 | 截断前先把较早消息整理成结构化摘要,保留目标、决定、偏好和未完成事项;完整原文继续存储,并记录摘要覆盖到哪条消息,便于回查。 |
| 摘要可能失真 | 模型归纳时可能遗漏细节、混淆时间顺序,或把推测写成事实。 | 后续回答依赖错误或过时的摘要。 | 采用增量摘要(旧摘要 + 新一段历史),要求区分讨论、决定和完成;只记有依据的内容,冲突标为待确认。校验摘要格式并保留原文和消息位置。 |
| 线程和隐私管理 | 线程标识使用错误会读错会话;历史也可能包含敏感信息。 | 上下文串线,或不必要地保留、发送敏感内容。 | 查询始终按已验证的 threadId 限定,并检查用户是否有权访问该线程;发送模型前按策略过滤敏感信息,支持清理或删除过期会话。 |
四、怎样从历史中挑出有用上下文
筛选要围绕当前问题和当前任务来做,而不是把历史越多越好。一个可解释、容易调试的第一版,可以把上下文拆成三部分:
任务摘要
目标、已确认决定、约束、进度、待办,以及仍有效的用户偏好。
近期原文
最近几轮完整消息,保留当前话题的细节、措辞和问答衔接。
再加上系统提示和当前问题,组成发给模型的本轮上下文。完整历史仍留在 SQLite,需要时可以按线程回查。
- 先保留系统提示、当前问题和最近几轮对话。
- 加入仍有效的目标、决定、约束、进度和待办。
- 若仍超预算,再压缩较早的消息;重复、无关或已被新决定替代的内容可省略。
- 新信息与旧信息冲突时,以用户明确更新的内容为准;无法判断时标记待确认。
五、长度控制与摘要如何配合
先计算本轮可用预算
输入可用预算 = 模型上下文上限 - 预留的回答空间 - 安全余量 历史可用预算 = 输入可用预算 - 系统提示、工具定义、当前问题及其他输入
实际 token 数应使用对应模型或服务提供的统计能力估算。字符数不能准确替代 token 数;API 返回的用量适合事后校准,发送前仍要做预算控制。
超过阈值时压缩较早消息
常见实现是直接调用大模型做摘要,或采用增量摘要:将旧摘要 + 新发生的一段较早消息交给模型,让它生成更新后的摘要。程序负责触发时机、选择消息范围、保存摘要和校验长度;模型负责理解并归纳内容。
触发条件:历史 token 估算超过阈值
↓
选取较早的一段消息
↓
旧摘要 + 这段消息 → 摘要模型
↓
保存新摘要与已覆盖的消息位置
↓
本轮上下文 = 摘要 + 最近消息 + 当前问题
摘要保留什么
| 字段 | 要记录的内容 | 示例 |
|---|---|---|
| 当前目标 | 仍在进行的任务 | 实现 Java 短期记忆 Demo |
| 已确认 | 明确决定或已经采用的方案 | SQLite 按 threadId 保存消息 |
| 进度与待办 | 已完成事项、下一步和未解决问题 | 基础会话已完成;待实现上下文裁剪与摘要 |
| 偏好与事实 | 仍有效的用户要求和必要的具体信息 | 函数需要注释;百炼额度错误码 |
摘要更新时应要求模型只记录消息明确支持的内容,区分“讨论过、已决定、已完成”,保留必要的数字、路径和错误码;冲突标为待确认。保存时可以记录摘要覆盖到哪条消息,避免重复摘要或漏处理消息。
总结
短期记忆让应用能够按会话保存并恢复交互历史。每轮请求时,应用取出当前线程的历史,与用户的新问题一起组成上下文交给模型;模型返回后,应用再保存本轮问答。随着历史增长,系统需要在 token 预算内筛选上下文:保留当前任务相关信息和近期原文,把较早对话压缩为可校验的摘要,同时保留完整历史供后续回查。
智能体记忆系统:短期记忆与长期记忆
智能体记忆系统:短期记忆与长期记忆
这节我们参考 LangChain 的 Memory 概念文档,梳理智能体记忆系统,包括短期记忆和长期记忆,并进一步了解不同类型的长期记忆及其管理方式。

在 LangChain Memory 一文中,Memory 被定义为一个用来记住先前交互信息的系统。对智能体来说,记忆不仅帮助它接续之前的对话,也能让它利用反馈、适应用户偏好。接下来我们就从它按回忆范围划分的两类记忆开始了解。
先给记忆系统一个定义
智能体的记忆系统,是一套在交互过程中保存、组织和取回信息的机制。它通过短期记忆维持当前会话的连续性,并通过长期记忆让有用的信息跨会话保留;在需要时,系统将相关记忆提供给大语言模型,用于生成回应或完成任务。
因此,记忆系统不只是数据库。它还涉及信息筛选、结构组织、检索、更新和过期清理。LLM 可以帮助提取或整理记忆,但应用需要管理保存范围、读写时机和数据校验。
两种记忆范围:短期与长期
短期记忆:跟随当前会话
短期记忆通常保存同一个会话线程(thread)中的消息历史和任务状态。一个 thread 可以看作一条独立的对话线;它不必等同于浏览器窗口或标签页,具体由应用如何分配会话 ID 决定。
在 LangChain Memory 一文中,短期记忆是 Agent state(状态)的一部分,并可通过与 thread 关联的 checkpointer(检查点机制)持久化。状态除聊天消息外,也可以包含任务进度、上传文件、检索结果或工具调用结果。恢复同一个 thread 时,应用可以读取相应状态继续处理。
检查点机制可以理解为在会话处理过程中,为某个 thread 的 state 保存一份带有标识的进度快照。后续请求带上同一个 thread ID 时,系统就能读取最近保存的检查点,恢复这条会话的消息和任务状态;如果程序中断,也可以从已保存的状态继续。它保存的是应用状态,不是让 LLM 自己记住内容。
同一 thread 的消息与任务状态
↓
Agent state
↓
checkpointer 持久化
↓
后续请求恢复同一 thread
对话历史会不断变长。把全部历史都塞进每次模型请求,可能超过上下文窗口,还会增加耗时和成本,并让旧的无关内容干扰当前任务。应用可以保留最近几轮、总结较早内容,或按任务筛选仍有用的状态。
长期记忆:跨会话保留
长期记忆保存用户或应用层面的信息,可以跨不同会话线程使用。例如,“用户偏好中文解释”可以在之后的新对话中取回。与当前会话的短期记忆不同,长期记忆关注的是哪些信息值得跨会话保留,并在之后的交互中按需取用。
长期记忆不是把全部历史永久放进提示词。系统会针对当前请求查找相关记忆,只把需要的信息提供给模型。还应考虑用户隔离、权限、信息更新和删除。
长期记忆可以保存什么?
LangChain 文档借用三种常见记忆类型来描述长期记忆的内容。它们不是和“短期、长期”互斥的另一套存储,而是回答“记忆里保存什么”。
| 类型 | 保存内容 | 智能体例子 |
|---|---|---|
| 语义记忆 | 事实与概念,记“知道什么” | 用户偏好中文回答 |
| 情景记忆 | 过去的事件或行动,记“经历过什么” | 某次任务怎样查询并核对订单 |
| 程序记忆 | 规则与做事方法,记“应该怎么做” | 客服处理退款前先核对政策 |
例如,“用户喜欢 Java 示例”是事实,属于语义记忆;“上次成功处理某个退款问题的步骤”是一次经历,可作为情景记忆;“退款前必须检查政策”是规则,可作为程序记忆。
注意:语义记忆(semantic memory)和语义搜索(semantic search)不是一回事。前者是所保存的记忆内容类型;后者是按照含义相似度查找信息的一种检索方法。
语义记忆的两种组织方式
语义记忆常保存用户、组织或其他对象的事实。LangChain 文档举出两种管理形态:集中维护一份 Profile,或把记忆拆成多个独立条目的 Collection。
Profile:更新一份档案
Profile 通常是有明确字段的 JSON 文档。系统把旧档案和新对话交给模型,让模型提出更新后的档案或 JSON Patch,再由应用校验并保存。
{
"preferred_language": "中文",
"example_language": "Java",
"answer_style": "简洁"
}
优点是整体画像集中,取用方便;随着字段增多,合并新旧信息时更容易遗漏或错误覆盖,因此需要明确 schema(数据结构)并进行校验,也可以按主题拆分档案。
Collection:维护一组记忆条目
Collection 是一组持续添加、更新或删除的独立记录。每条可以有自己的结构和元数据,例如内容、来源、创建时间和适用用户。
[
{"fact": "用户偏好中文回答", "type": "preference"},
{"fact": "用户正在学习 RAG", "type": "learning_goal"},
{"fact": "示例项目使用 Java", "type": "project_context"}
]
这种方式便于单条新增和检索,但需要处理重复、冲突、更新与删除;条目分散也可能让模型缺少它们之间的整体关系。使用 Collection 时,检索逻辑和记忆维护规则尤其重要。
可以这样选择:需要一个稳定、整体的用户画像时,Profile 较直观;信息来源多、条目持续增长且需要逐条搜索时,Collection 更灵活。系统也可以组合两者,例如用 Profile 保存当前偏好,用 Collection 保存具体经历。
情景记忆:用过去的例子帮助当前任务
情景记忆保存过去发生的事件或 Agent 的行动。在一些任务中,系统可以把成功的输入与输出保存为示例,再根据当前输入选择相似示例,作为 few-shot prompting(少样本提示)的一部分。
过去的案例:用户问订单进度 → 查询订单状态 → 用简洁语言说明 当前的问题 → 找到相关案例 → 把示例放进提示词 → 模型参考示例处理
关键不是把所有历史案例都交给模型,而是按当前输入挑选有帮助的例子。案例需要维护和评估,避免错误或过时的做法被反复模仿。
程序记忆:维护做事规则
对智能体来说,程序记忆可以体现在模型权重、Agent 代码和提示词中。通常由开发者控制代码与模型配置;应用也可以根据反馈改进提示词或规则。
例如,客服 Agent 的操作规则可以是:先确认用户身份,再查询订单;涉及退款时核对政策;信息不足时先向用户追问。系统可把近期反馈和旧指令交给模型,生成一版候选指令,但应经过审核或自动校验后再启用,不能把一次性要求误当作永久规则。
长期记忆何时更新?
记忆写入可以放在处理用户请求的主流程中,也可以放到后台异步执行。选择取决于信息是否需要立即生效,以及当前响应能否接受额外延迟。
| 方式 | 流程 | 特点 |
|---|---|---|
| 主流程即时更新 | 收到消息 → 更新记忆 → 回复用户 | 新记忆较快可用;增加本次响应耗时 |
| 后台异步更新 | 收到消息 → 回复用户 → 后台整理并写入 | 用户等待更短;新记忆稍后才可用,后台失败需重试或监控 |
例如,用户刚刚更正自己的语言偏好。如果下一轮请求必须立即使用新偏好,可以在主流程更新;如果记忆整理较慢且不影响当前回答,可以先回复,再异步写入。后台图示里的时间延迟只是说明处理可以稍后发生,并非固定等待时间。
设计时先回答四个问题
- 记什么? 区分当前任务状态、用户事实、历史经历和长期规则,避免把所有消息都当成记忆。
- 何时写? 决定在请求主流程即时写入,还是交给后台异步整理。
- 怎样取? 根据 thread、用户、任务、关键词或语义相关性,挑选当前真正有用的记忆。
- 何时更新或删除? 处理偏好变化、事实冲突、过期信息、用户撤回和数据保留期限。
短期记忆的重点是让当前任务接得上;长期记忆的重点是留下跨会话仍有价值的信息。两者一起工作,才构成可持续使用的智能体记忆系统。
延伸阅读
- LangChain:Memory 概念概览,介绍短期/长期记忆及语义、情景、程序记忆。
- LangGraph:短期记忆,介绍 thread state 与 checkpointer。
- LangGraph:长期记忆,介绍 Store、namespace、Profile 与记忆读写。
- CoALA:Cognitive Architectures for Language Agents,从认知架构角度讨论语言智能体的记忆、行动和决策过程。
实现一个简单的工具调用DEMO
实现一个简单的工具调用DEMO
本文围绕 Page9 的 Java Demo,梳理一个工具调用程序从启动、注册工具到处理模型请求并返回结果的整体设计。重点放在各个组件如何衔接,以及应用怎样控制工具的校验和执行。
一、程序设计整体流程图
先从程序运行顺序看整体结构。启动时,应用创建工具定义并注册到 Agent;每轮交互中,模型可以直接回答,也可以请求调用工具。工具结果回到模型后,流程继续循环。
注册表是流程的起点:应用启动时创建工具定义,交给 Agent 按名称保存。此后同一份注册表既用于向模型提供候选,也用于应用查找模型请求的本地执行函数。
二、按流程拆解关键设计
1. 定义工具:把说明和 Java 执行函数绑定
每个 ToolSpec 包含工具名称、用途说明、参数 JSON Schema 和本地执行函数。Demo 定义了计算器和本机日期两个工具,前者接收算式,后者不需要参数。
工具定义 = 名称 + 用途描述 + 参数 Schema + Java 执行函数 calculator(expression: string) get_current_date()
以计算器为例,工具名 calculator 与 Java 函数 Calculator.calculate(...) 在注册时绑定。模型只看到工具说明和参数定义,不会拿到这个 Java 函数。
2. 注册工具:应用维护唯一的工具表
程序启动时把工具列表传给 Agent。Agent 按工具名把它们放入 Map<String, ToolSpec>,形成注册表;重复的工具名会被拒绝。这个表有两个用途:提供本轮候选,以及在模型提出调用后查找对应的本地执行函数。
3. 提供候选:把工具定义放进模型请求
每轮调用模型时,程序从注册表取出可用工具列表。百炼适配器将每个工具转换为 Chat Completions 的 tools 项,包含 name、description 和 parameters,并设置 tool_choice 为 auto,表示让模型自行判断是否需要工具。
{
"type": "function",
"function": {
"name": "calculator",
"description": "计算只包含数字、括号和 + - * / 的算式。",
"parameters": {
"type": "object",
"properties": { "expression": { "type": "string" } },
"required": ["expression"],
"additionalProperties": false
}
}
}
这是模型可见的“工具菜单”。参数 Schema 告诉模型要提供怎样的输入,也供应用在执行前校验参数。
4. 读取模型响应:判断是否调用以及调用什么
程序读取 choices[0].message.tool_calls。如果没有工具调用,模型返回的文本就是最终答复;如果有调用,程序解析每项中的工具名、参数和调用 ID。
{
"tool_calls": [{
"id": "call_abc123",
"type": "function",
"function": {
"name": "calculator",
"arguments": "{\"expression\":\"123 * 456\"}"
}
}]
}
| 返回字段 | 应用怎样使用 |
|---|---|
function.name | 作为注册表查找键,决定请求哪个本地工具。 |
function.arguments | 解析为参数对象,校验后传给执行函数。 |
id | 作为 call_id 保存,之后用于关联工具结果。 |
5. 应用校验并执行:模型请求不等于允许执行
runTool() 先检查调用 ID,再按工具名在注册表查找。未注册的工具不会执行。找到工具后,validateArguments() 根据 Schema 检查必填字段、额外字段和基础类型;校验通过才调用注册时绑定的 Java 函数。
模型提出
“调用 calculator,参数是 expression=123*456。”这是调用请求,不是 Java 命令。
应用执行
查到注册项、校验参数后,应用调用 Calculator.calculate() 并取得结果。
未知工具、参数校验失败或执行过程发生异常时,程序不会把请求当成任意函数执行;它会生成一段错误结果,让模型知道本次工具调用没有成功。
6. 返回结果:用 tool_call_id 对应原调用
执行结果被包装成一条 role=tool 消息,并带回原来的调用 ID。这样模型能知道结果对应哪一次工具请求。
{
"role": "tool",
"tool_call_id": "call_abc123",
"content": "56088"
}
应用把这条消息追加到对话历史,再次请求模型。模型可以根据结果继续提出下一项工具调用,也可以直接组织最终答复。若一轮返回多个工具调用,程序会分别执行,并用各自的 tool_call_id 返回结果。
三、关键代码位置
| 流程环节 | 关键实现 |
|---|---|
| 工具定义 | ToolSpec、CALCULATOR、CURRENT_DATE |
| 建立工具注册表 | Agent 构造函数按工具名保存定义 |
| 提供候选工具 | Agent.run()、toApiTools()、DashScopeModel.complete() |
| 解析模型调用 | parseApiReply() 生成 ToolCall |
| 校验与执行 | runTool()、validateArguments()、ToolExecutor.execute() |
| 把结果交回模型 | Message.toolResult()、toApiMessages()、Agent.run() 下一轮循环 |
四、运行 Demo
先运行离线模式,可以在不调用模型 API 的情况下观察程序闭环。离线模型会固定模拟一次计算器调用;它不会根据你输入的问题进行推理。
cd "D:\data\ai学习\ai\Page9" mvn compile exec:java "-Dexec.args=--demo"
体验模型实际选择工具时,在 PowerShell 中设置百炼 API Key 和控制台里可用的模型名,再启动真实模式:
$env:DASHSCOPE_API_KEY = "你的百炼 API Key" $env:DASHSCOPE_MODEL = "控制台中可用的模型名" mvn compile exec:java
可以尝试“计算 123 × 456”“今天的日期是什么”或分成多步的算式。终端会显示提供给模型的候选工具、模型的调用请求、应用执行结果和后续模型轮次。输入 /reset 清空本次进程内对话,输入 /quit 退出。
五、Demo 与实际项目
这个 Demo 把工具调用核心闭环压缩在一个 Java 文件中,便于沿着数据流学习。实际项目的工具不一定是本地计算函数,也可能连接其他子系统的 API、企业服务或操作系统能力。变化的是工具背后的执行方式,整体闭环仍然相同。
调用其他子系统的 API
例如把订单查询封装为 get_order_status(orderId)。模型提出调用后,应用检查工具是否已注册、参数是否有效,以及当前用户是否有权查看该订单;随后由 Java API 适配器调用订单子系统,把响应整理成适合模型读取的结果,再作为工具消息交回模型。Java 可以用 HttpClient 发送 HTTP 请求,也可以使用目标系统提供的 SDK。
模型请求 get_order_status(orderId)
↓
应用校验工具、参数与用户权限
↓
Java API 适配器请求订单子系统
↓
整理响应并关联 tool_call_id
↓
结果交回模型
API 密钥和服务凭据由应用在服务端安全保存并附加到请求中,不应放进提示词或让模型生成。生产代码还要处理超时、服务错误、限流和重试;对可能重复产生副作用的请求,需要设计幂等保护。
调用操作系统工具
应用也可以把有限的系统能力包装成工具,例如查询磁盘空间、读取允许目录中的文件列表。能用 Java 标准库完成时,优先直接调用 Java API;确实需要启动外部程序时,可使用 ProcessBuilder,并由应用限定可运行的程序和参数。
不要开放一个让模型传入任意命令字符串的通用执行入口。应使用名称明确、范围有限的工具,校验每个参数,并设置执行超时、工作目录、输出大小和操作系统权限;高影响或不可逆操作还应要求用户确认。
系统和工具变多后使用 MCP
当需要连接多个独立服务时,可以通过 MCP 等协议统一工具的发现和调用。MCP Server 暴露工具名称、说明和输入 Schema,客户端先获取可用工具,再通过 tools/call 请求执行;这能统一连接方式,但不会自动替代应用的身份认证、授权和业务校验。可参考 MCP 工具规范。
当前 Demo 的 JSON Schema 校验只覆盖必填字段、额外字段和基础类型,并不是完整的 Schema 校验器。实际项目还要按具体 API 和系统能力补齐业务规则、权限检查、失败处理、超时、重试、审计和并发控制。
最重要的设计边界是:模型能够提出工具调用,但应用保留工具注册、权限校验和执行控制权。无论工具在本机、另一个子系统,还是 MCP Server 后面,应用都应在执行前做自己的检查。
总结
用户提出问题后,应用先把注册好的工具说明和对话一起交给模型。模型若返回调用请求,应用就按工具名查注册表、校验参数并运行对应的 Java 函数;工具结果带着原 call_id 回到对话中,模型再决定继续调用还是回答用户。
因此,工具调用不是模型直接执行本地代码,而是一种由模型提出请求、应用受控执行、模型读取结果并继续处理的协作闭环。Page9 的计算器和日期工具,就是这套机制的最小可运行例子。
理解智能体中的工具调用
理解智能体中的工具调用
上一篇《从最小 Agent 看懂工具、检索与记忆》里,我们用一个计算器认识了工具调用的基本闭环:模型提出调用请求,Java 程序执行函数,再把结果交回模型。
这个例子只有一个计算器。真实应用可能有查询订单、查库存、退款、发邮件等许多工具。工具多起来以后,问题就不只是“怎么调用”,还包括“工具怎样定义”“本轮把哪些工具交给模型”“程序怎样安全地执行”。本文结合 OpenAI Function Calling 指南,把这些问题连起来说明。
一、工具调用是什么,基本过程怎样实现?
先从一个简单例子理解工具调用,再逐步拆解模型和应用各自负责什么。下面五点回答两个基础问题:工具调用是什么?一个工具调用从定义到执行、回传结果,程序要怎样完成?
第一点:工具调用到底是什么?
先用一个天气查询例子说明。用户问:
“今天杭州天气怎么样?”
假设应用提供了一个天气工具 get_weather(city)。模型不会自己打开天气网站,而是根据问题和工具说明,判断需要查询天气,并提出类似“调用 get_weather,参数 city=杭州”的结构化请求。应用收到后才调用天气服务,再把天气数据交回模型组织答案。
用户问天气 ↓ 应用把问题和“天气工具说明”发给模型 ↓ 模型决定调用 get_weather(city="杭州") ↓ 应用执行天气查询 ↓ 查询结果返回给模型 ↓ 模型整理成自然语言答复
关键在于:模型提出调用请求,应用或工具服务才真正执行。模型通常不会自己连接天气网站;它会返回“请调用 get_weather,参数是杭州”这样的结构化请求。应用收到后,才运行对应函数并把结果交回模型。这个“定义工具—接收调用请求—执行—回传结果”的交互方式,可以对照 OpenAI 工具调用流程理解。
先认识 5 个基本概念
| 概念 | 在天气例子里是什么 |
|---|---|
| 工具 | 查询天气的能力,例如 get_weather |
| 工具定义 | 告诉模型工具叫什么、能做什么、需要哪些参数 |
| 调用请求 | 模型提出调用 get_weather,并传入 city="杭州" |
| 工具执行 | 应用真正查询天气服务 |
| 工具结果 | 天气服务返回的数据,再交给模型组织答案 |
因此,工具调用不是“模型说要查天气,天气就自动出现”。应用必须接收调用请求、执行工具,并把结果传回模型。
第二点:LLM 在工具调用中做什么?
LLM 根据用户任务、对话上下文和应用提供的工具定义,判断是否需要使用工具;如果需要,就选择工具并生成参数。收到工具执行结果后,LLM 再判断信息是否足以回答,还是需要继续调用其他工具。例如天气查询失败后,它可以根据返回的错误决定重试、换用其他工具或说明暂时查不到;查到天气后,也可以继续查询空气质量。
因此,LLM 负责判断下一步并生成调用请求,不负责实际运行工具。应用负责接收请求、校验参数与权限、调用对应函数,并把结果交回 LLM。LLM、应用和工具共同组成的系统才是 Agent;Agent 的完整行为不等同于 LLM 单独的行为。
第三点:用户、应用、模型和工具怎样配合?
以“今天杭州天气怎么样?”为例,可以沿着四个参与方看完整过程。应用是中间的协调者:它把问题和天气工具定义发给模型,接住模型的调用请求,执行天气查询,再将结果交回模型并把最终答案发给用户。
第四点:模型返回的调用请求长什么样?
模型不会把“我正在查天气”当作普通回答交给应用,而是返回一条机器可以读取的结构化调用项。OpenAI Responses API 的 function call 会带有调用标识、函数名称和参数。下面是简化示意:
{
"type": "function_call",
"call_id": "call_abc123",
"name": "get_weather",
"arguments": "{\"city\":\"杭州\"}"
}
name告诉应用要找哪个工具处理器。arguments是模型按工具参数定义生成的输入;应用需要解析并校验它。call_id用来将这次请求和稍后返回的工具结果对应起来。
字段形式会随 API 模式或服务商改变;核心信息是“调用哪个工具、参数是什么、这次调用的标识是什么”。这条数据是待执行请求,不是天气结果,也不是一段应该直接展示给用户的最终答案。
第五点:应用怎样执行调用并把结果交回去?
应用通常维护一个工具注册表,把每个工具名称映射到本地处理器。例如 get_weather 映射到天气服务客户端。处理器接收解析后的参数,执行相应操作,并将结果转成简洁、明确的文本或 JSON。
调用请求 name = get_weather
↓
工具注册表查找 get_weather 对应的处理器
↓
解析 arguments,校验 city 参数和访问权限
↓
调用天气服务,得到天气数据
↓
组成 tool result,并保留 call_id
天气服务返回数据后,应用再把结果作为对应调用的输出消息交回模型。Responses API 的消息大致如下:
{
"type": "function_call_output",
"call_id": "call_abc123",
"output": "{\\"location\\":\\"杭州\\",\\"temperature_celsius\\":20}"
}
这里的 call_id 与模型原先返回的调用请求相同,用来让 API 知道这是哪一次调用的结果。output 可以是 JSON、普通文本或错误信息;应用随后再次请求模型,模型根据工具结果生成面向用户的答复,或提出下一次工具调用。
实现时的重要边界:不要把模型返回的工具名直接当成任意 Java 方法名去执行。应用应使用明确的白名单映射(例如 switch 或工具注册表)分发请求;未注册的工具名直接拒绝。调用请求仍须经过参数校验、权限检查,以及必要的用户确认。
二、实际项目中,工具多了怎么办?
理解基本调用闭环后,实际系统还要面对规模和业务复杂度:用户的问题属于哪个领域?本轮应该开放哪些工具?工具达到数百或上千个时,怎样避免全部发送给模型?下面讨论这些落地问题。
第六点:工具很多时,每轮提供哪些工具?
如果只有天气查询、计算器等少量工具,应用可以把这些工具定义一起发给模型,让模型根据问题决定是否调用以及调用哪一个。工具达到几十、几百甚至上千个时,通常不会把完整清单每轮都发送;工具说明会占用上下文,候选过多也会增加选错的机会。
常见做法是先由应用缩小范围,再由模型做具体选择。应用可以结合用户原话、当前页面、业务对象和权限信息,先判断业务类别;例如“退款进度”进入支付/退款工具组,“某商品还有库存吗”进入商品/库存工具组。接着,只把这一组里可授权的少数候选工具交给模型。
场景判断可以使用固定规则、页面入口、分类器或轻量模型。类别明确时规则简单可靠;表达方式多时,可以让分类模型从预设类别中选择。类别不确定时可放宽候选集合或追问。无论模型把请求分到哪个场景,最终权限检查仍由应用执行。
第七点:1000 个工具要一起发给模型吗?Tool Search 怎么做?
通常不需要。可以先建立工具目录,保存每个工具的名称、用途描述、所属领域、参数摘要和权限信息;请求到来时,应用先按场景和权限过滤,再用关键词或向量搜索从描述中找出少量候选工具。
OpenAI Function Calling 指南还介绍了 Tool Search:初始请求不必暴露所有低频工具,模型可以先搜索工具目录,找到相关工具后再加载其定义并调用。截图所示指南注明,内置 tool_search 的支持范围以文档列出的模型为准,页面标明 GPT-5.4 及之后的模型支持。其他模型/框架也可以通过应用侧的工具目录检索实现类似的按需发现流程。
大量工具目录(不全部发给模型)
↓ 按上下文、场景、权限过滤
本轮候选工具(仍过多时继续检索)
↓
模型从候选中选择 / 按需 Tool Search
↓
应用校验并执行所选工具
第八点:框架可以替我们做哪些事?
工具调用可以手写,也可以用框架减少重复工作。框架通常帮助注册工具、生成工具定义、解析模型调用请求、分发到处理器、回传结果并继续模型循环;它不会自动理解企业业务,也不会替应用决定用户权限。
- LangChain4j:Java 框架。可用
@Tool描述方法,也可通过ToolProvider按请求动态提供工具;官方工具文档还包括面向大工具集的搜索机制。 - Spring AI:适合 Spring 应用,可用
@Tool或 ToolCallback 暴露 Java 方法,并用 ChatClient 管理常见的调用往返。
对于学习和小规模 Demo,先看清自己写出的“请求—调用—执行—回传”最容易理解;业务复杂、需要动态工具、错误恢复和观测时,再让框架接管通用环节。
总结:工具调用的完整闭环
定义工具 → 根据场景与权限提供候选工具 → 模型选择并提出调用 → 应用校验并执行 → 结果回到模型 → 模型继续回答或调用下一项工具
用户提出任务后,应用整理可信上下文,并把适合本轮且有权限的工具定义提供给模型。模型判断直接回答还是提出结构化调用请求;应用解析请求、校验参数与权限,执行对应工具,再把结果和调用标识交回模型。Agent 根据结果决定继续调用、换工具、追问、转人工或结束。模型最终给出答复,应用再返回给用户。模型负责提出行动并根据观察结果决策,应用负责授权、执行和控制整个调用边界。
参考:OpenAI Function Calling 指南、LangChain4j Tools、Spring AI Tool Calling。
如何让 RAG 系统更可靠地找到所需资料
如何让 RAG 系统更可靠地找到所需资料
上一篇《一个DEMO认识最小RAG》里,我们从一个最小向量 RAG Demo 出发,编写了基本流程。
这条流程构成了最小 RAG 的基础。接下来,我们继续看一个实际问题:当简单的向量检索没能稳定找到所需资料时,可以怎样改进?查询改写、复杂问题拆解、混合检索和重排序分别处理不同环节,检索评估则帮助判断改动是否有效。
四种改善检索的方法
1. 查询改写:让搜索词表达得更清楚
用户的提问可能很口语、依赖上下文,或者没有包含资料里的关键词。查询改写会在保持原意的前提下,把问题整理成更适合搜索的完整表达。
例子:对话中用户问“那酒店呢?”,系统可以结合前文改写成“公司差旅政策对酒店住宿报销有什么规定?”。这样检索时不必猜“酒店”指什么。
技术实现思路
- 在调用现有
retrieve()前,增加rewriteQuery(question, history)。这个函数通过聊天 API,把当前问题和必要的最近对话发送给同一个大语言模型。 - 在这次 API 请求的提示词中,明确要求模型只返回一个独立、明确的检索问题,并保留人名、产品名、编号、日期和数字,不要补造用户没说过的条件。
- 用改写后的问题做检索,但最终生成回答时仍传入用户的原问题和检索片段。
- 如果改写为空、超时或格式异常,就回退到原问题,保证检索主流程可以继续。
// rewriteQuery 内部调用聊天 API,返回模型改写后的检索问题 String searchQuery = rewriteQuery(question, recentHistory); List<Hit> hits = retrieve(searchQuery, chunks); // 回答仍使用用户原问题,并附上检索到的资料 String answer = askModel(question, formatContext(hits));
LangChain4j 中可用查询转换组件处理这一步,例如把依赖对话上下文的追问压缩成独立查询。组件负责组织流程,具体改写仍由所配置的模型完成。
2. 复杂问题拆解:分别搜索问题中的多个方面
一个问题如果同时询问多个对象、条件或步骤,单次搜索可能只照顾到其中一部分。拆解会把它转成多个独立、完整的子问题,分别检索,再把证据合并起来回答原问题。
例子:“比较 Milvus 和 PostgreSQL 做混合检索时的检索能力、改造成本和运维成本”,可以拆成检索能力、接入现有 Demo 的改造量、部署运维差异三个子问题。
技术实现思路
- 先判断问题是否包含多个目标;可以用简单规则做初筛,再让模型输出结构化的子问题列表。
- 要求每个子问题脱离原句也能看懂,并限制数量,例如最多 3~5 个;简单问题就只返回原问题或一个改写问题。
- 相互独立的子问题并行调用检索;有先后依赖时按顺序检索。
- 把各路结果按片段 ID 去重,再用 RRF 等方式融合排名;最终用原始问题和合并后的证据生成回答。
List<String> queries = decompose(question); // 结构化子问题 List<List<Hit>> resultLists = retrieveInParallel(queries, index); List<Hit> evidence = rrfMergeAndDeduplicate(resultLists); String answer = askModel(question, formatContext(evidence));
LangChain4j 的 QueryTransformer 可以把一个查询转换成一个或多个查询;检索编排器再把多路结果交给聚合器。子问题并行会缩短等待时间,但会增加检索调用量。
3. 混合检索:同时照顾语义和关键词
向量检索擅长找到意思相近的内容;关键词检索擅长匹配产品型号、政策编号、人名等精确词语。混合检索把两种结果合并,减少只依赖其中一种方式带来的漏检。
例如搜索“报销上限”,语义检索可能找到标题为“差旅费用标准”的片段;如果用户问的是型号“X200”,关键词检索又能帮忙精确命中包含该型号的资料。
技术实现思路
- 索引中同时保留片段原文和向量。检索时分别执行关键词检索(如 BM25)和向量检索,各自先取一批候选,例如各取前 20 条。
- 用 RRF 合并两个排名列表。对片段
d,累加它在各列表中的1 / (c + rank)分数;没出现的列表不计分。c是平滑常数。 - 按融合分数排序并去重,再交给后续重排序或直接取最终 Top K。
向量检索 Top 20 ─┐
├─ RRF 合并、去重 ─→ 候选结果
BM25 检索 Top 20 ─┘
在我们当前的本地 Demo 里,可以先分别实现 vectorSearch() 和 keywordSearch(),再实现 rrfMerge()。生产环境可以把检索存储替换成支持混合检索的 Milvus,再通过 Java SDK 执行稠密向量和 BM25 搜索;Milvus 的混合搜索接口支持用 RRF 或加权排序器融合结果。LangChain4j 可以负责查询转换、检索器和结果聚合等流程编排,底层数据库仍由对应的存储连接器负责。
4. 重排序:把最相关的候选资料放前面
初次检索的目标是尽量找全候选资料;重排序则对候选结果逐条评估它们与当前问题的相关程度,再重新排列。之后系统只取前几段加入提示词。
比如初次检索拿到 20 段,重排序后把真正解释“酒店报销上限”的片段放在最前,而把只提到“差旅”的泛相关片段排后。
技术实现思路
- 先用向量、关键词或混合检索快速召回较宽的一批候选,例如前 20~50 段。
- 把原问题与每个候选片段一起交给重排序模型(常见做法是 Cross-Encoder / Reranker),让模型直接判断“这段资料对回答这个问题有多相关”。
- 按相关性分数重新排序,必要时设最低分阈值,再只取前几段(如 3~5 段)进入最终提示词。
候选片段 + 原问题
↓ Reranker 逐段打分
按相关性排序 → 过滤低分 → 取 Top K
LangChain4j 提供 ReRankingContentAggregator,可接入实现了 ScoringModel 的评分 / 重排序模型。RRF 与 Reranker 的工作不同:RRF 融合多个检索列表的名次;Reranker 再用查询与片段的匹配程度精排。重排序能提升精排质量,但会增加一次模型调用的延迟和费用,所以通常只处理有限候选集。
检索评估:检查系统到底有没有找对
检索评估主要用于离线检查和迭代,不是每次用户提问时必需的一步。先准备一组代表性问题,并由人标注每个问题应该命中的资料,再让系统检索,比较实际结果与标注结果。
| 指标 | 直白解释 | 常用于发现 |
|---|---|---|
| Recall@K | 应该找到的相关资料,有多少出现在前 K 条里? | 重要资料有没有漏掉 |
| Precision@K | 前 K 条结果中,有多少确实相关? | 结果里无关内容是否太多 |
| MRR | 第一条相关资料排得有多靠前? | 最有用证据是否容易被模型优先看到 |
例如,测试集标注了 2 段相关资料,系统返回前 3 段并命中其中 1 段,那么 Recall@3 = 1/2。如果最相关的资料排在第二位,那么该问题的 MRR = 1/2。
技术实现思路
- 给索引片段分配稳定 ID(例如来源路径加章节 / 片段标识),建立固定测试集;每项记录问题、应命中的片段 ID,以及可选的问题类型标签。
- 在固定语料和相同 Top K 下,批量运行检索,记录每个问题实际返回的片段 ID 和名次。
- 用人工标注的相关片段计算 Recall@K、Precision@K、MRR;对有多个相关片段且关心整体排序的场景,也可加 NDCG@K。
- 对比基线向量检索与“改写 / 拆解 / 混合检索 / 重排序”版本的分数,同时记录延迟和调用成本,查看具体失败案例。
{
"question": "酒店住宿每晚最多能报销多少?",
"relevantChunkIds": ["travel-policy#hotel-limit"]
}
对当前 Java Demo,第一版可以自己写一个离线评测入口:读取 JSON 测试集,调用 retrieve(),按相关片段 ID 计算指标并输出结果表。这样不需要先引入评估框架,也能直接比较每次检索改动的影响。以后如果需要评估最终回答的忠实度、相关性和引用质量,再引入 Ragas 等面向 RAG 的评测工具;评估框架不能替代高质量的人工测试集。
常见评估框架怎么选?
| 框架 / 平台 | 主要用途 | 适合什么情况 |
|---|---|---|
| Ragas | 面向 RAG 的评估库。提供 Context Precision、Context Recall 等检索相关指标,也有 Faithfulness、Response Relevancy 等回答评估指标。 | 希望用一套 RAG 指标检查检索上下文和最终回答;它以 Python 为主,Java 项目可导出评测数据后单独运行评估。 |
| LangSmith | 管理测试集、运行实验和对比版本,也能结合规则评估、人工标注或 LLM-as-judge。 | 需要持续记录调用过程、追踪失败案例,并比较不同模型、提示词或检索配置。 |
| Microsoft Foundry Evaluators | 提供 Document Retrieval 等 RAG 评估器;有人工相关性标签时可评估检索排名,没有标签时也可用模型评估检索上下文相关性。 | 系统已经使用 Microsoft Foundry,希望在该平台上运行和查看评测结果。 |
| LangChain4j | 主要用于 Java 应用中的 LLM 与 RAG 流程构建,包括查询转换、检索和结果聚合;评测指标可由应用自行实现或接入其他评估工具。 | Java 开发者希望在代码里组织 RAG 流程;不要把它与专门的 RAG 评估平台混为一谈。 |
评测集怎么准备?
- 收集真实用户可能会问的问题,覆盖常见问法和不同难度。
- 为每个问题标注应该命中的资料片段,或至少标注相关来源文件。
- 运行检索,记录候选结果、排名和分数。
- 计算指标,并抽查失败案例,判断是查询、召回还是排序出了问题。
- 调整一个环节后重新评测,比较改动前后的结果。
如果按来源文件而不是片段标注,指标只能反映“找对了来源”,无法精确判断是否命中了来源中的正确段落。随着评测成熟,可以逐步改为片段级标注。
把五项内容放回一张图里
| 方法 | 解决的问题 | 作用位置 |
|---|---|---|
| 查询改写 | 问题表达不清或缺少检索所需信息 | 搜索之前 |
| 复杂问题拆解 | 一个问题包含多个检索目标 | 搜索之前 |
| 混合检索 | 只用语义或只用关键词都可能漏掉结果 | 召回候选资料时 |
| 重排序 | 初次检索结果中相关资料排序不理想 | 初次召回之后 |
| 检索评估 | 不清楚系统是否找对、改动是否有效 | 离线验证与迭代 |
可以把前四项看作检索链路中的改进工具,把检索评估看作检查这些工具是否有效的反馈办法。系统不必无条件启用全部方法:简单问题可能只需原始查询和向量检索;问题有多个方面时再拆解;精确词和语义都重要时用混合检索;候选较多或顺序不理想时再重排序。
最重要的判断
如果评估显示相关资料没有被召回,优先检查查询处理和召回方式;如果资料已经召回但排得靠后,再检查排序和重排序。这样才能针对实际问题改进,而不是盲目叠加技术。
把方法串成一条检索链路
对于一条复杂查询,在线流程可以概括为:
查询改写 / 拆解 → 每个查询分别执行向量检索 + BM25 → RRF 合并并去重 → Reranker 精排 → 取 Top K 片段,与原问题一起交给生成模型
在 Java 项目中,这些组件可以按职责组合:LangChain4j 用于编排查询转换、多个检索器和结果聚合,并可接入重排序模型;Milvus 负责存储文本与向量,并执行向量和 BM25 检索,Java 应用可通过 Milvus Java SDK 或相应适配器调用;RRF 用于融合多个检索列表的排名;评测代码则读取带有相关片段标注的数据集并计算指标。LangChain4j 与 Milvus 的具体连接方式取决于所选版本和集成模块。学习阶段也可以先用 Java 自行实现这些步骤,再逐步替换为框架和数据库组件。