实现一个最小的RAG

实现一个最小的RAG

这一小节,我们把之前的简单文本匹配检索升级为向量检索,并沿着代码看清楚:资料如何变成可检索的索引,问题如何找到相关片段,模型又如何根据这些片段组织答案。

本节 Demo 的代码位于 Gitee 仓库的 Page5 目录:https://gitee.com/toxplore/ai/tree/master/Page5

先看整体流程

RAG 可以拆成两个阶段:先为资料建立索引;收到问题后,再用索引找到相关资料并生成回答。

1准备资料索引读取、清理、切分、向量化
保存或复用本地索引
→
2检索相关片段把问题转成向量
相似度排序并取 Top K
→
3生成回答问题 + 命中片段
发送给聊天模型

一、资料怎样变成索引

程序从 data/ 读取 Markdown、纯文本和结构较简单的 HTML 文件。读取时会做基础清理;HTML 会移除标签、脚本和样式。随后按段落切分,再调用嵌入模型把片段转换为数字向量。

嵌入模型和聊天模型分工不同。
text-embedding-v3 把文本转换成向量;聊天模型负责根据问题和检索资料生成自然语言答案。向量相似度比较由 Java 程序完成。

Chunk 保存什么?

每个 Chunk 代表一个可检索片段,包含来源文件、片段正文和嵌入向量。List<Chunk> 就是这些片段组成的列表:

Chunk {
  source     // 来源文件,例如 data/RAG入门.md
  text       // 片段正文
  embedding  // 片段的嵌入向量
}

切分规则:先保留段落,太长再拆

splitIntoChunks() 先按空行识别段落,再尽量把相邻段落组合到不超过 CHUNK_SIZE = 420 个 Java 字符的片段里。只有单独一段已经超过这个长度时,才由 splitLongParagraph() 继续拆分,并让相邻片段重叠约 CHUNK_OVERLAP = 60 个字符。

因此 420 是这个教学 Demo 的示例参数,不是所有资料都适用的最佳值。现实项目会考虑标题、章节、句子等文档结构,并通过真实问题评估切分效果。

二、buildOrLoadIndex():复用缓存或重建索引

  1. 调用 loadDocuments() 读取资料,并计算资料内容与嵌入模型的指纹。
  2. 如果没有强制重建,而且本地索引存在、模型和指纹都匹配,就调用 readChunks() 从 JSON 恢复片段和向量。
  3. 如果缓存不存在或不匹配,就重新切分资料,并分批调用嵌入 API 生成向量。
  4. 调用 writeIndex() 把片段、来源和向量写入 index/vector-index.json,然后返回 List<Chunk>。
启动时会不会处理全部资料?
当前 Demo 每次启动都会读取资料并计算指纹;若索引有效,就加载缓存,不会重新切分或向量化。资料一旦变化,则当前实现会重新建立全部资料的索引,还没有做增量更新。

图:buildOrLoadIndex() 的缓存复用与索引重建分支

buildOrLoadIndex 缓存复用与索引重建流程

三、interact():一轮提问如何流动

  1. 进入交互循环,等待并读取用户问题;/quit 退出,/reindex 强制重建索引。
  2. 普通问题交给 retrieve(question, chunks)。
  3. retrieve() 调用 embed(question) 将问题转成向量,并遍历片段列表,逐一计算余弦相似度。
  4. 检索结果按分数降序排列,返回 TOP_K = 3 个片段。
  5. interact() 把问题、命中片段和来源拼成上下文;askModel() 再连同系统提示词一起发给聊天模型。
  6. 程序打印答案,并回到循环等待下一问。

图:interact() 调度输入、检索和回答的过程

interact 用户提问、检索、生成回答的循环流程
余弦相似度比较什么?
它衡量问题向量和片段向量方向的接近程度。分数用于排序,不代表答案正确概率。TOP_K = 3 表示最终返回三个片段,不表示只比较三个片段。

四、重要函数对照

函数 职责
loadDocuments() 遍历资料目录并读取支持的文件。
htmlToText() 移除 HTML 标签和脚本样式,整理文本。
splitIntoChunks() 按段落组合文本片段。
buildOrLoadIndex() 检查指纹,加载已有索引或重新构建。
embed() 调用百炼嵌入 API,把文本转换为向量。
readChunks() / writeIndex() 从本地 JSON 恢复索引,或保存新索引。
retrieve() 嵌入问题、计算相似度、排序并选出 Top K。
cosineSimilarity() 计算两个向量的余弦相似度。
interact() 读取用户输入、调度检索与生成、打印回答。
askModel() 把系统提示词、问题和检索上下文发送给聊天模型。

五、Demo 的边界与现实系统

现在的 Demo

本地 JSON 保存所有片段向量;每个问题会遍历全部片段算相似度。结构简单,便于看懂数据如何流动。

规模变大以后

通常用向量数据库或搜索引擎保存和索引向量,以近似最近邻等索引方法加速查找,并通过后台流程增量更新资料。

因此,目前的 retrieve() 是教学用的穷举比较:资料片段多时,计算和内存成本会增长。未来升级时,可以把本地列表扫描替换为向量数据库的 Top K 查询。

运行与观察

$env:DASHSCOPE_API_KEY = "你的百炼 API Key"
cd "D:\data\ai学习\ai\Page5"
mvn compile exec:java
  • 首次建索引会调用嵌入 API;资料和模型未变化时复用本地索引。
  • 每个问题会调用嵌入 API 生成问题向量,并调用聊天 API 生成答案。
  • 输入 /reindex 强制重建,输入 /quit 退出。
本节的核心认识
RAG Demo 不只是“把资料发给模型”:程序先准备可搜索的向量索引,再用问题从索引中挑出少数相关片段,最后把这些片段作为上下文交给聊天模型。嵌入模型负责表示文本,检索代码负责选片段,聊天模型负责组织回答。
本节围绕 Page5 的最小向量 RAG Demo,梳理资料索引、缓存、切分、检索与生成的代码流程。

 

 

从资料到回答:理解 RAG 的完整流程

从资料到回答:理解 RAG 的完整流程

上一篇我们从一个最小 Agent 出发,认识了工具、检索和记忆。这次,我们聚焦其中的检索增强生成(Retrieval-Augmented Generation,RAG),从“什么时候需要它”开始,逐步看它怎样准备资料、检索内容、生成回答,并判断效果好不好。

本文参考 Microsoft Learn 的 RAG 基础概念单元整理。我们之前的 Java Demo 也会作为对照:它完成了文档切分和简单匹配,但还没有使用嵌入向量或向量索引。

先看整体流程

准备资料并建立索引 → 用户提问 → 检索相关片段 → 将片段加入提示词 → 模型生成回答 → 评估并持续改进

什么时候会用到 RAG?

判断什么时候需要 RAG,可以先看大模型自身的三类限制:

  • 知识边界:模型不会自动知道企业内部资料、私有文档或刚刚上传的文件。
  • 知识时效性:模型训练时学到的信息可能已经过时,也不一定包含最新制度、产品变化或业务数据。
  • 可验证性:模型可能给出听起来合理的回答,却没有可靠来源支持,用户也难以核对依据。

当问题受到这些限制影响时,就可以考虑使用 RAG:先从可信、已授权且尽量保持最新的资料中检索依据,再让模型根据这些内容回答。例如员工问“海外出差住酒店最多能报销多少?”,系统先查公司的差旅制度,并在回答中提供可核对的来源。

如果只是简单常识或计算,模型本身就能可靠回答,通常没必要额外检索。RAG 也会增加数据准备、检索、延迟和费用。

RAG 的整体框架:三个核心步骤

RAG 可以概括为三个连续步骤:先检索相关资料,再把资料加入提示词,最后让大模型依据上下文生成回答。

RAG 的三个核心步骤 检索相关资料,将资料加入提示词上下文,再由大模型生成回答。 123 检索Retrieve 增强Augment 生成Generate
检索相关资料 → 将资料加入提示词 → 模型依据上下文生成回答

第一阶段:准备资料,建立索引

检索只能找到已经整理并放进搜索系统的资料。正式回答问题前,RAG 系统需要先构建索引。这个阶段通常在资料新增或更新时运行。

RAG 索引准备流程:从资料中提取文本,切分片段,生成嵌入向量,并建立搜索索引
索引构建:提取文本 → 切分片段 → 生成向量 → 建立索引

1. 选择并处理数据来源

资料可以来自产品手册、制度、网页、数据库或客服工单。系统要加载内容、提取文字和结构,并清理重复页眉、导航文字等无关内容。还可以补充标题、来源链接、类别、更新时间和访问权限等元数据,方便后续筛选、追溯和权限控制。

资料质量会影响后面的检索和回答:重复、过时或互相矛盾的内容会让结果变差;权限信息也要保留,确保用户只检索到自己有权查看的资料。

2. 把文档切分成片段

文档通常太长,不能每次都整篇交给模型,因此会切成较小的文本片段(chunks)。片段要有足够上下文,同时聚焦在一个主题上。太大片段会带入无关内容、占用更多上下文;太小则可能把标题、定义或条件拆散。相邻片段有时会少量重叠,避免重要信息刚好落在切分边界两侧。

例如,差旅制度可以按章节切成机票、酒店、餐饮和市内交通。用户问酒店额度时,系统只需检索酒店相关片段,不必把整份制度都交给模型。

3. 生成嵌入向量

嵌入模型把文本转换成一组数字向量,用来表示文本的语义。意思相近的文字,即使使用不同的词,向量通常也更接近。比如“酒店住宿标准”和“住一晚最多能报多少”,用词不同但语义相近。

建立索引时,用嵌入模型处理文档片段;用户提问时,通常还要用同一个嵌入模型处理问题,再比较向量。嵌入模型负责把内容变成便于比较的向量,生成式大模型则负责根据提供的内容组织回答。

4. 建立并维护索引

索引或向量数据库保存文本片段、对应向量和元数据,供后续搜索。索引可以支持关键词检索、向量检索或两者结合的混合检索。新增资料要加入,已修改的内容要更新,失效的内容要移除,所以数据准备不是一次性的任务。

换嵌入模型或向量维度后,通常要重新生成文档向量并重建索引,保证文档和用户问题仍能在同一种向量表示下比较。

第二阶段:检索资料,生成回答

当用户提问时,RAG 应用会在运行时执行一条链路:

RAG 在线流程:用户提交问题,系统检索索引,将相关上下文加入提示词,再由模型生成回答
在线流程:提问 → 检索 → 补充上下文 → 生成回答

检索:找出够用的证据

使用向量检索时,系统会用与建索引时同类的嵌入模型,把用户问题也转换成向量,再和文档片段的向量比较。比如“海外会议住酒店能报多少”可能匹配标题为“国际住宿费用”的条款,即使用词不完全相同。

检索可以通过元数据过滤、混合检索和重新排序来改善。混合检索同时利用关键词匹配与语义相似;重新排序会把更相关的候选片段排在前面;限制结果数量,则避免把弱相关或重复内容都塞进提示词。

系统要找的不是最多的内容,而是足以支撑答案的最少资料。实际中通常先取前 K 条,再通过测试调整 K 值和分数门槛;相关度分数只是参考,并不自动保证答案完整。

增强提示词:把依据交给模型

应用会把系统规则、用户问题和检索片段组合起来,并要求模型依据所给资料回答;如果资料里没有答案,要说明证据不足;如果有来源信息,则附上出处。

一个简化的提示词

规则:根据下面的制度片段回答。若片段没有答案,请说明没找到依据。
问题:海外出差住酒店最多能报销多少?
资料:[差旅制度 / 海外住宿] 每晚最高报销 500 元。

检索到的文档是待参考的数据,不是新的系统指令。文档里可能出现“忽略之前规则”等文字,应用应明确要求模型不要让资料内容覆盖系统规则。

生成:给出有依据、可核对的回答

模型根据增强后的提示词生成回答。应用最好同时展示来源标题、章节或链接,让用户核对答案依据。这样的回答称为有依据的回答(grounded response),但不代表绝对正确:检索结果可能不完整,模型也可能误解资料,资料本身还可能过时。

当没有足够证据时,系统应承认没找到,而不是猜测。在医疗、法律、财务等高影响场景,还需要人工复核。

第三阶段:评估 RAG 是否有效

RAG 是一条由检索和生成组成的流水线。回答听起来流畅,并不能证明整个系统工作良好。评估时要分开检查:检索是否找对资料,以及模型是否正确使用资料。

评估检索

  • 相关性:检索片段是否在回答问题?
  • 覆盖度:完整回答所需的信息是否都找到了?
  • 排序:最有用的片段是否排在前面?
  • 权限与时效:用户是否有权看这些资料?资料是否仍有效?

如果正确资料没有被检索到,单纯修改生成提示词通常解决不了问题。应检查资料质量、切分边界、元数据、查询处理方式、检索策略和排序。

评估生成

  • 有依据:回答中的每个关键说法都能在资料中找到支持吗?
  • 相关与完整:是否直接回答问题,并覆盖必要信息?
  • 引用质量:引用的来源是否确实支持对应说法?
  • 合理表达不确定性:资料不足时是否避免猜测?

自动系统怎样评估?

系统不会凭感觉知道答案好不好。通常需要先准备一组代表性测试问题,为每题标记正确来源片段和可接受答案,再自动记录检索结果和模型回答。规则检查适合金额、编号等明确字段;另一个模型也可以按标准评估回答是否有依据、是否完整。

例如问“海外住宿额度是多少”,如果测试集中标注的正确来源是《差旅制度》的“海外住宿”章节,就可以检查这段是否出现在前几条检索结果中。若检索正确、回答却把 500 元说成 800 元,问题可能出在生成;若正确条款没找到,则先排查检索。

“回答符合检索资料”不一定等于“事实正确”,因为来源自身也可能错或过期。高影响场景需要可信参照和人工抽查。

平衡质量、延迟与成本

检索并处理更多片段,可能提高信息覆盖度,但也会增加提示词长度、响应时间和费用。内容太多还可能让模型难以聚焦。因此要通过评估找到适合当前场景的检索数量、排序和提示词设置,而不是默认片段越多越好。

上线后持续观察用户哪些问题没答上、引用是否可靠、检索是否变慢,以及知识资料是否过期。资料、模型、提示词或检索参数变化后,都应重新评估。

回到我们的 Demo

之前的 Java Demo 从 HTML 中提取文本、切成片段,并用字符相似度为片段打分,再把匹配结果和问题一起发给模型。这展示了 RAG 的基本思路,但还没有使用嵌入模型或向量索引。

如果继续升级,可以先替换字符匹配:用嵌入模型分别处理文档片段和问题,再用向量相似度检索;随后添加来源元数据、混合检索、重排序和自动评估。每次升级都用同一组测试问题比较,才能知道改动是否真的有效。

本篇要点

RAG 的核心不只是“把资料塞给大模型”,而是把资料准备好、在问题到来时找对证据、让模型依据证据回答,再用系统化测试持续检查检索和生成的效果。

参考资料

Microsoft Learn:Introduction to retrieval-augmented generation concepts

Microsoft Learn:RAG evaluation guidance

理解智能体中的五种Workflow

 

理解智能体中的五种Workflow

上一篇我们从一个最小 Agent 出发,认识了模型、工具、检索和记忆。这次,我们再往前一步,看看当任务变复杂时,程序可以怎样组织模型调用。

当任务需要分几步完成时,程序可能会多次请求模型,并将每次结果传给后续步骤。一次这样的“请求—回答”就是一次模型调用(LLM Call)。

接下来介绍的五种工作流模式,关注的是如何组织这些调用:提示链、路由、并行化、编排者—工作者和评估者—优化者。它们也可以根据任务需要组合使用。

1. 提示链:按步骤依次完成

Prompt Chaining(提示链)把一个任务拆成固定顺序的步骤。前一步的输出成为后一步的输入,每次模型调用各自完成一个范围清楚的小任务。

用户输入
   ↓
LLM Call 1:生成文章提纲
   ↓
Gate:检查提纲是否覆盖必需章节
   ↓ 通过
LLM Call 2:根据提纲撰写正文
   ↓
输出文章

其中的 Gate(检查点)通常是程序执行的一条明确规则,例如字段是否齐全、格式是否有效、长度是否超限。检查失败时,程序可以停止、报错或安排重试。Gate 不一定是一次额外的模型调用。

提示链适合步骤和先后顺序都比较确定的任务,例如先生成营销文案再翻译,或先写提纲、检查提纲后再写全文。它让每一步更容易检查和调整,但需要多次调用模型,整体耗时也会增加。

2. 路由:先判断,再分流

Routing(路由)先对输入分类,再把它送到最适合的后续任务。路由器可以是一次模型调用,也可以是规则或传统分类模型。

用户问题
   ↓
Router:识别问题类型
   ├─ 普通咨询 → LLM Call 1:知识问答
   ├─ 退款请求 → LLM Call 2:退款流程
   └─ 技术问题 → LLM Call 3:技术支持
                     ↓
                  返回结果

路由适合输入类别清楚、不同类别需要不同提示词、模型、工具或业务流程的场景,例如客服问题分流。它能让每个后续流程专注处理自己的任务;同时要考虑分类错误,并准备合适的兜底路径。

3. 并行化:同时处理,再汇总

Parallelization(并行化)让多个模型调用同时工作,再由程序汇总结果。它有两种常见变体:分段和投票。

分段:分工做不同的事

程序把任务拆成彼此独立的部分,并行交给不同调用。例如审查代码时,一个调用检查安全问题,一个检查性能,另一个检查可读性,最后把发现合并。

投票:多次做同一件事

程序把同一个问题交给多个调用,让它们独立判断,再按照多数票、评分或阈值等规则综合结果。例如多个调用分别判断一段内容是否为垃圾信息,再由程序统计判断。

同一输入
   ├─ LLM Call 1 ─┐
   ├─ LLM Call 2 ─┼→ Aggregator:合并结果 → 输出
   └─ LLM Call 3 ─┘

程序通常会异步发出这些请求,再等待所需结果完成后汇总;也可以设置超时,或在达到票数后提前结束。并行化适合子任务互不依赖、需要缩短等待时间,或希望通过多次判断提高信心的情况。它会增加模型调用量,汇总规则也需要设计清楚。

4. 编排者—工作者:动态拆解和委派

Orchestrator-Workers(编排者—工作者)适合任务复杂、开始时难以预先列出所有子任务的情况。一个中心模型先分析输入,动态拆解任务并分派给工作者,最后综合结果。

用户的复杂任务
   ↓
Orchestrator:分析任务并安排工作
   ├─ Worker 1:完成子任务 A ─┐
   ├─ Worker 2:完成子任务 B ─┼→ Synthesizer:检查并整合 → 输出
   └─ Worker 3:完成子任务 C ─┘

例如,修改一个涉及多个文件的软件项目时,编排者可以先分析改动范围,再安排工作者分别处理接口、页面和测试。若这些子任务互不依赖,就可以并行;若后一步依赖前一步结果,编排者也可以安排顺序执行。

它和固定的并行分段不同:固定分段通常由程序事先规定工作内容;编排者—工作者则会根据具体输入决定需要拆出哪些工作。代价是编排和整合更复杂,也要防止重复、遗漏或互相冲突的结果。

5. 评估者—优化者:根据反馈反复改进

Evaluator-optimizer(评估者—优化者)让一个模型先生成结果,另一个模型或程序按评价标准检查,并将反馈交给生成者修改。这个过程重复进行,直到通过检查或达到停止条件。

输入
   ↓
Generator:生成初稿
   ↓
Evaluator:依据标准检查
   ├─ 通过 → 输出
   └─ 未通过 + 修改意见 → Generator 再生成

例如翻译任务可以检查术语、语气和遗漏,再让生成者按意见修改。它适合评价标准明确、反复修改能带来可衡量提升的任务。应设置最大迭代次数,并让反馈具体可执行;否则模型可能一直循环,或在模糊反馈下反复改写却没有实质改善。

五种模式怎么选?

  • 步骤和顺序固定:考虑提示链。
  • 输入类别不同,需要走不同流程:考虑路由。
  • 子任务独立,或需要多次判断:考虑并行化。
  • 子任务要根据输入临时确定:考虑编排者—工作者。
  • 有清晰标准,值得迭代打磨:考虑评估者—优化者。

这些模式并不互斥。一个复杂系统可以先用路由识别任务,再由编排者安排工作者并行处理,最后用评估者检查结果。选择时先看任务步骤是否固定、子任务是否独立、是否需要动态拆解,以及结果能否用明确标准评估。

这些模式怎么实现:自己编排还是用框架?

两种方式都可以。框架不会替我们决定业务流程;它主要提供模型接入、状态管理、节点编排、并发执行、重试和持久化等基础设施。即使使用框架,我们仍要定义每一步做什么、步骤之间怎样传递数据,以及什么条件下继续或停止。

如果目标是学习原理,或流程只有几步,自己编排通常最清楚。以 Java 为例,可以把每一步写成方法:提示链按顺序调用方法;路由用分类结果选择分支;并行化用线程池或 CompletableFuture 发出独立任务,并等待结果后汇总;编排者—工作者先让模型生成结构化子任务,再由程序分派给工作者;评估者—优化者则用有上限的循环完成“生成—评价—修改”。

自己实现的好处是调用过程透明、容易调试,也能直接看见工作流和普通 Java 代码的关系。代价是系统变复杂后,需要自己补上超时、重试、并发限制、错误处理、运行记录和状态保存;如果这些细节没有设计好,流程中断后可能无法恢复,模型调用也可能无限重试。

如果项目已经使用 Spring,可以用 Spring AI 的 ChatClient 统一调用模型,并组合工具调用等能力。它帮我们处理一些模型交互环节,但具体工作流仍由应用程序编排。

对于更复杂的 Java Agent 工作流,可以进一步看 Spring AI Alibaba Graph 和它的 Agent Framework。Graph 把步骤组织成节点,让节点通过共享状态和条件边连接;框架也提供顺序、并行、路由和循环等常用结构,并支持更完整的流程编排能力。采用前要核对项目所需的 JDK、Spring 和依赖版本;当前仓库说明要求 JDK 17 或更高版本,而本章的基础 Demo 使用 Java 11。

一个实用的学习路径是:先用普通 Java 把五种模式分别写出来,确认自己理解了调用顺序和数据流;当开始需要持久化状态、失败恢复、人工审核、追踪每个节点等能力时,再引入框架。这样换了框架,也仍然知道它帮我们接管了哪部分工作。

小结

这五种模式关心的都是同一个问题:程序如何安排模型完成工作。提示链强调依次执行,路由强调分类分流,并行化强调同时处理,编排者—工作者强调动态拆解,而评估者—优化者强调反馈循环。理解这些模式后,我们就能根据任务特点设计合适的调用流程,而不是把所有工作都塞进一次模型请求。

 

从最小 Agent 看懂工具、检索与记忆

从最小 Agent 看懂工具、检索与记忆

上一篇《什么是 AI Agents》里,我们把 Agentic System 的基础构件归纳为增强型 LLM:一个被检索、工具和记忆增强过的大语言模型。

这次,我们从一个最小 Agent 开始,逐个理解这些构件,并通过编码实现它们。本章涉及的示例代码都集中放在项目目录中:工具调用在 MinimalAgentLoop.java,检索在 MinimalRagDemo.java,记忆在 MinimalMemoryDemo.java。

代码仓库:https://gitee.com/toxplore/ai/tree/master/Page2

从一个最小 Agent 开始

我们先让模型解决一个简单问题:计算 123 × 456。

如果直接让模型回答,它可能会自己算,也可能算错。现在我们给它一个计算器工具,让它在需要时调用:

用户提问
  ↓
模型判断是否需要计算
  ↓
模型请求调用 calculator
  ↓
Java 程序执行计算
  ↓
程序把结果交回模型
  ↓
模型生成回答

这里有个容易混淆的地方:模型并没有真的运行计算器。它只是根据请求中提供的工具说明,返回“调用哪个工具、参数是什么”。真正执行计算的是我们的 Java 程序。

我的理解:模型负责决定要不要用工具,程序负责执行工具。两边来回配合,就构成了最小的工具调用循环。

工具:让模型能够采取行动

为什么我们可以把工具“交给”大模型?这不是因为模型能直接访问 Java 程序,而是模型服务商在 API 中提供了 Function Calling(函数调用)能力,约定了调用方和模型之间如何描述工具、传递调用请求和返回执行结果。

调用方把工具名称、用途和参数格式随请求一起发送。支持 Function Calling 的模型会结合用户问题和工具说明,选择直接回答,或者按约定的结构返回一个工具调用请求。这个请求只是“请程序调用某工具并传入这些参数”,还没有真正执行函数。

程序 ──工具说明 + 用户问题──→ 模型
程序 ←──结构化工具调用请求── 模型
程序执行工具,再把结果作为消息发回模型

所以,Function Calling 可以理解为模型服务提供的一种结构化 API 交互方式:模型负责选择工具并生成参数,应用程序负责校验、执行和回传结果。不同服务商提供的格式相近,但细节不一定完全相同。

把工具交给模型使用,可以拆成三步:在程序里注册工具;把工具说明发给模型并读取模型的调用请求;由程序执行工具,再把结果交回模型。

1. 设置并注册工具

这里注册一个名为 calculator 的计算器。工具说明和参数结构会发给模型;最后一行的执行函数留在 Java 程序本地。

private static final ToolSpec CALCULATOR = new ToolSpec(
    "calculator",
    "计算只包含数字、括号和 + - * / 的算式。",
    "{\"type\":\"object\",\"properties\":{\"expression\":{\"type\":\"string\"}},"
        + "\"required\":[\"expression\"],\"additionalProperties\":false}",
    arguments -> Calculator.calculate(
        String.valueOf(arguments.get("expression"))));

2. 把工具交给模型,读取它的调用请求

请求中包含对话消息、工具定义和 tool_choice: auto。这表示模型可以根据问题自行选择调用工具,也可以直接回答。

requestJson.set("messages", toApiMessages(messages));
requestJson.set("tools", toApiTools(tools));
requestJson.put("tool_choice", "auto");

如果模型决定调用计算器,会返回类似这样的数据:

{
  "tool_calls": [{
    "id": "call_123",
    "function": {
      "name": "calculator",
      "arguments": "{\"expression\":\"123 * 456\"}"
    }
  }]
}

Java 程序读取返回里的工具名和参数,转换成内部的 ToolCall 对象:

String callId = callNode.path("id").asText();
JsonNode function = callNode.path("function");
String name = function.path("name").asText();
JsonNode argumentsNode = function.path("arguments");
JsonNode parsedArguments = argumentsNode.isTextual()
        ? mapper.readTree(argumentsNode.asText())
        : argumentsNode;
Map<String, Object> arguments = mapper.convertValue(
    parsedArguments,
    new TypeReference<Map<String, Object>>() {});

calls.add(new ToolCall(callId, name, arguments));

3. 执行工具,把结果交回模型

程序根据工具名找到本地注册的执行函数,用模型给出的参数运行计算器。接着把结果作为一条 tool 消息加入对话,再请求模型继续回答。

private String runTool(ToolCall call) {
    ToolSpec tool = tools.get(call.name);
    if (tool == null) {
        return "工具错误:未注册的工具 " + call.name;
    }
    try {
        return String.valueOf(tool.executor.execute(call.arguments));
    } catch (Exception exception) {
        return "工具执行失败:" + exception.getClass().getSimpleName()
                + ": " + exception.getMessage();
    }
}
    

Agent 循环拿到执行结果后,再把它作为工具消息追加到会话;随后继续调用模型:

String result = runTool(call);
conversation.add(Message.toolResult(call.name, call.callId, result));

回传结果后,Agent 循环会再次请求模型。模型读到计算结果,才生成面向用户的最终回答。未注册的工具会被拒绝;工具执行失败时,程序会把简短错误信息交给模型处理。

我的理解:工具的“说明”给模型看,工具的“执行函数”由程序掌管,模型提出调用,程序执行并返回结果。

本次 Demo 使用的是阿里云百炼的 Function Calling 接口。其他模型服务也可能提供类似能力,但字段和行为不一定完全一致;更换模型或服务时,仍要查看对应文档。

检索:先找到资料,再让模型回答

如果我们问的是常识问题,通常可以直接问模型。但如果问题涉及一份指定文档,比如“《什么是 AI Agents》里怎么总结 Agent 的基础构件?”,模型不一定看得到这份文件。

这时可以先从文档里找到相关段落,再把它和问题一起发给模型:

读取文档 → 切分片段 → 检索相关片段
                       ↓
             片段 + 用户问题 → 模型回答

我们的 RAG Demo 就是这样工作的:启动时读取 HTML、切成文本片段;用户提问后,程序给片段打分,挑出匹配度最高的几段,再把它们拼进模型请求中。

代码先取出得分最高的三个片段,拼成上下文,再把上下文和原问题一起交给模型:

List<Hit> hits = retrieve(question, chunks, 3);
StringBuilder context = new StringBuilder();
for (int i = 0; i < hits.size(); i++) {
    Hit hit = hits.get(i);
    context.append("[片段 ").append(i + 1).append("] ")
           .append(hit.text).append("\n\n");
}

String answer = askModel(apiKey, baseUrl, model,
                         question, context.toString());

模型请求中的用户消息则把资料片段和问题放在一起:

messages.addObject()
    .put("role", "user")
    .put("content", "资料:\n" + context
            + "\n用户问题:" + question);

当前 Demo 用的是字符二元组重叠:把问题拆成相邻的两个字符,统计每个片段命中了多少组,再按命中比例排序。它只是一个帮助理解流程的简单算法,不是语义检索。比如“基础构件”和“基本组成”意思相近,但字面字符差异较大,简单匹配可能找不到。

实际项目可以把文档提前处理并建立索引,收到问题后只取相关的少量内容。检索的价值在于让模型回答时能够参考指定资料,而不是只依赖模型原本掌握的知识。

记忆:让模型能够接续对话

模型不会自动记住上一次 API 调用的内容。如果希望它接续之前的聊天,程序需要把对话历史再次放进请求中。

最简单的做法是保留消息列表。每次请求时,程序把这些历史消息和新问题一起发给模型,模型于是能根据上下文回答。

Demo 中,每次请求先把较早对话摘要放进 system 消息,再把近期历史逐条加入请求:

String system = "你是一个有短期对话记忆的助手。请结合本轮提供的记忆摘要和近期对话回答。"
        + (memory.summary.isEmpty() ? ""
        : "\n较早对话摘要:\n" + memory.summary);
messages.addObject().put("role", "system").put("content", system);

for (ChatMessage message : memory.messages) {
    messages.addObject()
        .put("role", message.role)
        .put("content", message.content);
}

但对话变长后,每次都提交全部历史会增加 token 消耗,也可能超过模型的上下文长度。我们的记忆 Demo 用了一个简单办法:保留最近几轮对话;较早的内容超过上限后,让模型压缩成摘要。

较早对话 → 摘要
近期对话 → 保留原文

摘要 + 近期对话 → 下一次模型请求

达到上限后,程序把较早对话交给模型生成新摘要,再用新摘要替换旧摘要并移除对应的旧消息:

if (memory.shouldCompress()) {
    String summary = summarize(apiKey, baseUrl, model, memory);
    memory.applySummary(summary);
}

上面的代码是压缩入口。Demo 在近期消息数超过上限时触发压缩,通常按完整的问答对处理,不把一轮对话拆开:

boolean shouldCompress() {
    return messages.size() > KEEP_RECENT_MESSAGES + 1;
}

int count = messages.size() - KEEP_RECENT_MESSAGES;
count = Math.max(2, count - (count % 2));

接着,summarize() 把要压缩的旧消息整理成对话文本,同时把已有摘要和旧对话一起发给模型。模型会在旧摘要基础上补充信息,而不是每次从零开始总结:

StringBuilder oldTranscript = new StringBuilder();
for (int i = 0; i < count; i++) {
    ChatMessage message = memory.messages.get(i);
    oldTranscript.append(message.role.equals("user") ? "用户:" : "助手:")
            .append(message.content).append('\n');
}

messages.addObject().put("role", "user").put("content",
        "已有摘要:\n" + memory.summary
                + "\n\n较早的对话:\n" + oldTranscript);
String newSummary = postChat(apiKey, baseUrl, body);

拿到新摘要后,程序保存它,并从消息列表中删除已经压缩的旧消息;最近几轮仍以原文保留:

summary = newSummary;
messages.subList(0, count).clear();

压缩后的摘要不是把对话逐字缩短,而是尽量保留后续还可能用到的信息,例如用户偏好、已确认的决定、当前任务和未完成事项。当前 Demo 将摘要和近期消息保存在 Java 进程的内存中,程序退出后就会清空;如果需要跨重启保留记忆,再将它们写入文件或数据库。

检索和记忆有什么区别?

两者都可能把信息放进模型上下文,但信息来源和用途不同:

能力 主要用途 例子
检索 从资料中找依据 查找 HTML 里的相关段落
记忆 延续之前的对话 记住已确认的决定

检索通常面对预先准备好的资料;记忆保存的是交互过程中值得后续使用的信息。工具则是执行计算、查询或其他动作。

把这些构件放回 Agent

工具、检索和记忆可以组合在一个 Agent 里,但不是每个问题都需要用到全部能力:

用户输入
   ↓
读取相关记忆
   ↓
需要参考资料时进行检索
   ↓
模型决定:直接回答,还是调用工具
   ↓
程序执行工具并回传结果
   ↓
模型生成回答
   ↓
更新记忆

如果下一步由模型根据问题和工具结果动态决定,流程就更接近 Agent;如果下一步始终由代码预先规定,则更接近 Workflow。

小结

  • 工具让模型可以请求程序执行操作。
  • 检索从外部资料中找出相关内容,交给模型参考。
  • 记忆让程序在后续请求中重新提供有用的对话信息。
  • 历史压缩能减少每次请求携带的对话内容,但摘要可能遗漏信息,需要根据实际用途设计。

这些 Demo 都刻意保持简单:计算器只处理基本运算,检索使用字面匹配,记忆只存在进程内。它们的目标是先看清楚数据如何流动,再逐步替换成更实用的实现。

下一步

接下来可以把计算器、文档检索和对话记忆组合进同一个 Agent,并打印每一步的输入、检索结果、工具调用和记忆变化。这样就能从三个独立 Demo 走向一个完整的小型 Agent。

什么是 AI Agents?

什么是 AI Agents?

2024 年底,Anthropic 发布了《Building effective agents》[1]一文,系统地讲述了如何构建基于 LLM 的 agent 应用。本文是我读这篇文章的笔记。

图片

“Agent”一词有多种用法:有些人把它理解为能长时间独立运行、自己用各种工具完成复杂任务的完全自主系统,另一些人则用它指沿着预先定义好的流程执行的实现。

Anthropic 不去争论哪种才算“真正的 agent”,而是把这些变体统称为 agentic system(智能体系统),再在内部划一条架构上的界线:

  • 工作流(workflow):LLM 和工具通过预先定义的代码路径编排。
  • Agent:LLM 动态指挥自己的流程和工具使用,掌控任务如何完成。

我的理解:agentic system、workflow 与 agent

理解了这条界线,再回头看日常语境,就容易分辨了。

我们日常熟悉的 agent,通常是“自己想办法把事办成的 AI”,比如 Claude Code 会自己读代码、改文件、跑测试,并根据报错继续修复。这类系统下一步做什么由模型决定,属于狭义的 agent。

相反,在 Coze、Dify 这类平台上拖拽搭出来的“智能体”,往往是固定的节点流程加上几次 LLM 调用。按 Anthropic 的标准,它们属于 workflow。但无论是 workflow 还是 agent,都归入 agentic system 这个大类。

因此,判断一个系统是 workflow 还是狭义的 agent,关键看控制权在谁手里:步骤由代码决定的是 workflow,由模型决定的是 agent。两者对比如下:

对比项 Workflow Agent
谁决定步骤 你的代码 模型
路径 固定,可预测 灵活,不确定
类比 你说怎么干就怎么干的员工 能自己想办法解决问题的员工

什么时候该用 agentic system

图片

分清了这几个概念,接下来的问题是:实际做项目时,真的需要用它们吗?文章的策略可以总结为一句话:先找最简单的方案,需要时再逐步升级。

Anthropic 建议,用 LLM 做应用时先找最简单的解决办法,只有确实需要时才增加复杂度,甚至可能根本不需要做 agentic system。原因是 agentic system 通常要拿延迟和成本去换更好的任务表现,所以要先想清楚这笔交换划不划算。

如果确实需要更复杂的方案,可以这样选择:

  • 任务定义清楚、需要可预测性和一致性时,用 workflow
  • 需要灵活性、需要模型在大规模场景下自己做决策时,才用 agent
  • 但对很多应用来说,把单次 LLM 调用优化好(加检索、加上下文示例)通常就够了

我的理解:升级阶梯

把上面的策略落到具体项目中,可以设置一个升级阶梯:从最简单的层级开始,只有当前层级无法满足需求时,才升到下一级。当然,复杂的项目可能各个层级都会用到。

层级 做法 适用情况
1 单次 LLM 调用 + 检索 + 示例 大多数应用到这里就够了
2 Workflow(固定的多步编排) 任务明确,要稳定、可预测
3 Agent(模型自己决定步骤) 步骤无法预先确定,需要灵活应对

怎么搭建 agentic system

确定了该用哪个层级,接下来就是动手实现。Anthropic 在文中也讲了何时、怎么使用框架。

图片

起步阶段可供选择的框架包括:Claude Agent SDK、Strands Agents SDK(AWS)、Rivet 和 Vellum。前两个是写代码用的 SDK,后两个是可视化工具。

框架的好处是上手快:它帮你调用 LLM、定义和解析工具、把多次调用串联起来。但它也有代价:通常会增加抽象层,遮住真实的提示词和模型返回,让调试变得困难,还容易诱使人在简单方案就够用时增加复杂度。

所以 Anthropic 建议:先直接用 LLM API,很多模式几行代码就能实现;如果用框架,一定要搞懂底层代码,因为对底层的错误假设是常见的出错来源。

常见的设计模式

无论是否使用框架,搭建时都会用到一些常见的设计模式。文章一共提出了 5 种 workflow 模式和 1 种 agent,它们都建立在同一个基础构件之上,所以先从这个构件讲起。

基础构件:增强型 LLM

图片

增强型 LLM 是 agentic system 最基本的构件,指的是被检索(retrieval)、工具(tools)和记忆(memory)增强过的 LLM。现在的模型能主动使用这些能力:自己生成搜索查询,自己挑选合适的工具,自己决定保留哪些信息。

图中三条双向虚线表示来回的交互:

能力 交互方式 作用
检索 Query / Results 从外部资料中查信息
工具 Call / Response 调用外部功能,如搜索、计算、读写文件
记忆 Read / Write 保存和读取需要留存的信息

实现这些增强能力时,Anthropic 建议抓住两点:一是按具体用例定制,二是给 LLM 提供简单、文档齐全的接口。其中一种做法是使用 Model Context Protocol(MCP)[2],通过一个简单的客户端接入不断增长的第三方工具生态。

需要注意的是,文章后面的所有模式,都默认每一次 LLM 调用已经具备这些能力。换句话说,下面每个模式里出现的“LLM”,其实都是一个增强型 LLM。

5 种 workflow 模式

在增强型 LLM 之上,把多次调用按预先定义的代码路径组织起来,就得到了 workflow。文章介绍了 5 种:

模式 一句话理解
Prompt chaining(提示链) 把任务拆成固定的先后步骤,上一步的输出是下一步的输入
Routing(路由) 先给输入分类,再分流到不同的专门流程
Parallelization(并行化) 多个 LLM 同时处理再汇总,包括分段(Sectioning)和投票(Voting)两种变体
Orchestrator-workers(编排者与执行者) 中心 LLM 动态拆分任务,分给多个执行者,再整合结果
Evaluator-optimizer(评估与优化) 一个 LLM 生成,另一个评估并反馈,循环改进

Agent

与上面 5 种不同,agent 不再依赖预先定义的流程:模型自己规划、调用工具,并根据环境反馈循环推进,直到完成任务。它是整个体系里控制权最彻底地交给模型的一类。

小结

  • 概念:agentic system 是总称,包含 workflow 和 agent 两类。区分标准只有一条:下一步做什么,是代码决定的,还是模型自己决定的。
  • 选型:先找最简单的方案,需要时再逐步升级。大多数应用,单次 LLM 调用加检索和示例就够了;任务明确、要求稳定时用 workflow;步骤无法预先确定时才用 agent。
  • 实现:先直接用 LLM API,搞懂底层后再考虑框架,避免被抽象层遮住提示词和模型返回。
  • 基础与模式:所有模式都建立在增强型 LLM(检索 + 工具 + 记忆)之上,在此基础上有 5 种 workflow 模式和 agent。

整篇文章的核心态度可以概括为:克制地使用复杂度。agent 不是越多越好,而是在简单方案确实不够时的选择。

下一步

了解完概念和模式,接下来该动手实践了:

  • 手写第一个最小 agent 循环
  • 给它加 2~3 个工具,并打印每一步日志
  • 逐个深入 5 种 workflow 模式,每种写一个小例子

参考文献

[1] Erik Schluntz, Barry Zhang. Building effective agents. Anthropic, 2024. https://www.anthropic.com/research/building-effective-agents

[2] Anthropic. Model Context Protocol. https://modelcontextprotocol.io

文中提到的框架与工具(出处同 [1]):Claude Agent SDK、Strands Agents SDK(AWS)、Rivet、Vellum。