跳到主要内容

langchain4j — 本课题摘录

读了哪几篇: 02-ai-services(一轮的中央装配流水线)、03-tool-calling(工具调用的往返循环)。 其余四篇(核心抽象层、结构化输出与护栏、RAG、多 agent)本轮没读。

这一家最值钱的一条:它把"一轮到底要按什么顺序做哪些事"写死成一条流水线,而且明说"顺序不是随意的"。

它对本课题回答了什么

决定一:一轮的装配顺序 —— 本课题最完整的一份清单

它把"直接调模型时每次都要手写的模板代码"列成五件,然后把这五件全收进框架:拼系统/用户消息、把历史塞进请求并把新回答塞回历史、检索并拼进提示词、要 JSON 再解析成对象、模型要调工具时执行、回填、再调一次

真正的装配顺序是十七步,每一步都依赖前一步的产物:

① 取会话记忆
② 拼系统消息(+ 变换)
③ 拼用户消息(模板变量填充)
④ 发「开始」事件
⑤ 检索增强 —— 只改用户消息
⑥ 合并多模态内容
⑦ 输入护栏
⑧ 判断是不是流式
⑨ 定输出格式(JSON schema 或追加格式指令)
⑩ 装配消息列表 + 写进会话记忆
⑪ 内容审核(异步提交)
⑫ 建工具上下文
⑬ 发请求拿到第一个回复
⑭ 进入工具往返循环 ← 循环在这里,不在最外层
⑮ 输出护栏
⑯ 按返回类型解析
⑰ 发「完成」事件并返回

(依据:前沿库 · LangChain4j · AiServices:一个 Java 接口如何变成一次 LLM 调用 —— AiServices 的 invoke 是十七步固定顺序的中央编排,文档明说「顺序不是随意的,每一步都依赖前一步的产物」,工具往返循环是其中第 14 步)

这张清单对我们直接有用:它就是"一轮到底有几件事"的答案。 我们的最小循环只需要其中的 ①②③⑩⑬⑭,但知道完整清单才知道自己省了什么。

两个顺序上不显然、容易踩坑的点:

  1. 检索增强只改用户消息,不改系统消息。 检索到的内容一定拼在用户消息里,系统消息只作为只读上下文传进去。 (依据:前沿库 · LangChain4j · AiServices:一个 Java 接口如何变成一次 LLM 调用 —— retrievalAugmentor.augment 的输出被强转回 UserMessage,system message 只作为只读 Metadata 传入——检索内容一定拼在用户消息里)
  2. 输入护栏排在检索之后。 所以护栏看到的是已经注入检索内容的最终用户消息,不是用户原始输入。想拿原文的护栏得自己从参数里取。 (依据:前沿库 · LangChain4j · AiServices:一个 Java 接口如何变成一次 LLM 调用 —— 顺序是 RAG → 多模态合并 → 输入护栏,护栏看到的是已注入检索内容的最终用户消息而非原始输入)

第 2 条是个很好的"顺序决定语义"的例子:同样两个部件,前后一换,护栏防的东西就变了。

决定四:轮数熔断,而且要看清它数的是什么

循环体是十步,第一步就是熔断:剩余往返次数减到零就抛异常。默认上限 100。

关键细节:它数的是"模型返回带工具调用的轮数",不是工具个数——一轮里模型调五个工具只算一次。 (依据:前沿库 · LangChain4j · 工具调用:从 @Tool 反射到 round-trip 循环 —— maxToolCallingRoundTrips 默认 100,数的是模型返回带工具调用的轮数而非工具个数,一轮调五个工具只算一次)

这条要记进配方。 "最多 20 轮"到底是二十次模型调用还是二十个工具调用,不同库口径不一样,写文档时必须说清。

决定三:补偿回滚,而且要改写历史

这是这一家独有的一条,而且很重要。

工具出错时,如果配了补偿动作,它不只回滚副作用,还把记忆里那条"成功"的工具结果换成"已回滚"并标成错误。 (依据:前沿库 · LangChain4j · 工具调用:从 @Tool 反射到 round-trip 循环 —— 补偿回滚不只撤销副作用,还把记忆里那条「成功」的工具结果改写成「已回滚」并置 isError=true,比「回滚了但不告诉模型」可靠得多)

它给的理由一句话:这比"回滚了但不告诉模型"可靠得多。

想清楚这个场景: 模型调了"下单",成功;下一个工具失败,系统把订单回滚了。如果历史里还留着"下单成功",模型下一轮就会基于"订单已存在"继续推理——它会去查一个不存在的订单。 这是"历史必须反映真实世界"的一个硬例子。

决定三补充:两个错误处理器的默认值刚好相反

错误类型默认处理分界线
参数错快速失败(直接抛)这是配置问题,重试一万次也不会好
执行错回喂模型这是运行时问题,常常重试就好

(依据:前沿库 · LangChain4j · 工具调用:从 @Tool 反射到 round-trip 循环 —— 参数错默认 fail fast、执行错默认回喂模型,分界线是「这是配置问题还是运行时问题」)

这条把"错误即消息"这条通则切了一刀:不是所有错都该喂回模型。 cline / semantic-kernel / haystack 都是"一律喂回",这一家区分了。这是本课题一个值得记的分歧。

决定二:工具从 Java 方法反射成 schema

方法名当工具名、注解文本当描述、每个参数当 schema 的一个属性。工具重名在注册时就抛错。

决定一补充:工具清单每轮重算,而且只增不减

循环第九步会重算动态工具。明确不删工具,而且无变化时返回原对象。 (依据:前沿库 · LangChain4j · 工具调用:从 @Tool 反射到 round-trip 循环 —— refreshDynamicProviders 明确不删工具且无变化时返回原对象,「只增」保证模型不会在下一轮突然找不到上一轮用过的工具)

理由很实在:"只增"保证了模型不会在下一轮突然找不到上一轮用过的工具。

这跟 deepagents 的"每轮现场增删工具"是对立的。 对立点:动态删工具能省上下文,但会让模型困惑——它上一轮还能用的东西这一轮没了。

它的做法(可以抄的部分)

用消息的附加属性当跨轮状态通道。 工具搜索找到的工具、已激活的技能,不存在某个会话对象里,而是挂在工具结果消息的属性上

好处:状态随对话记忆一起持久化,换个进程恢复对话,已搜到的工具、已激活的技能自动还在。 代价:那几个属性名变成了兼容性契约,源码里写了"不要改"的注释。 (依据:前沿库 · LangChain4j · 工具调用:从 @Tool 反射到 round-trip 循环 —— 工具搜索结果与已激活技能挂在 ToolExecutionResultMessage 的 attributes 上而非会话对象里,好处是随对话记忆一起持久化、换进程恢复后仍在)

单工具不开线程。 只有工具数大于一才走并发,注释写得很直白:只有一个工具时另起线程不划算。

校验分两级:能早报的早报,不该报的先憋着。 注册时能查出来的非法组合立刻拦掉;只有在真开启某个可选功能时才会暴露的配置错,先存着,等真开启时才抛。

它没回答什么

  • 历史怎么压——这两篇不管长对话瘦身。
  • 不用原生工具调用怎么办——它假设模型支持。
  • 循环状态怎么存——状态在会话记忆里,但没有"整个运行可续跑"这个概念。

坑与代价

  • 模型幻觉出不存在的工具名,默认直接炸。 那个策略枚举目前只有"抛异常"一个值;想要"告诉模型这工具不存在、让它重选"必须自己传一个函数进去。 (依据:前沿库 · LangChain4j · 工具调用:从 @Tool 反射到 round-trip 循环 —— HallucinatedToolNameStrategy 枚举目前只有 THROW_EXCEPTION 一个值,想让模型重选必须自己传 Function)

    对比 rig 的五档恢复,这里是最粗的一档。幻觉工具名是高频事件,默认炸掉不合适。

  • 补偿失败只写日志,不会升级成调用失败。 框架保证"尽力回滚",不保证"回滚成功"。
  • 必填校验不对称: 必填的对象类型参数缺失时静默传空,只有原始类型才报错。官方承认这是当前版本的行为,计划下个大版本修。
  • 工具向量搜索每轮现建索引,而且缓存从不自动清理。 它承认这是有意的简化,理由是"工具数量有限"——这个理由在工具规模变大时就不成立了。