AiServices 源码级拆解:一个接口如何变成 RAG Agent
AiServices 源码级拆解:一个接口如何变成 RAG Agent
一句话结论:
AiServices不是魔法,而是“动态代理 + 上下文装配 + 消息构建 + 输出解析”的运行时。理解装配顺序,才能定位 90% 的“工具不触发、结构化输出失败、记忆不生效”。适用版本:LangChain4j
1.23.0/ JDK21
运行方式:内存模式 + Mock,无需 Docker / PgVector / Redis
0. 先说结论
先看三个真实问题:
- 加了
@Tool,模型就是不调用; - 返回 POJO 偶尔解析失败;
- 加了
chatMemoryProvider,多轮对话还是“失忆”。
它们看起来是三个问题,实际上都指向同一件事:你没有按 AiServices 的装配顺序去理解它。
本文按一次方法调用的生命周期拆开:
- 接口如何变成代理;
- 代理如何把方法参数变成
ChatRequest; - 记忆、RAG、工具在什么时机被织入;
- 返回值如何被解析;
- 出问题时,应该先看哪一层。
1. 四步:从接口到代理
AiServices 的核心流程只有四步:
定义接口
→ builder 收集配置
→ 生成动态代理
→ 方法调用时装配 ChatRequest、调用模型、解析返回值
1.1 定义接口
interface SupportAssistant {
@SystemMessage("你是企业客服助手,回答必须基于资料。")
String chat(@MemoryId String conversationId,
@UserMessage String question);
}
接口没有实现类。@SystemMessage、@UserMessage、@MemoryId 是给代理看的“元数据”。
1.2 builder 收集配置
SupportAssistant assistant = AiServices.builder(SupportAssistant.class)
.chatModel(chatModel)
.chatMemoryProvider(id -> MessageWindowChatMemory.withMaxMessages(20))
.contentRetriever(retriever)
.tools(new OrderTools())
.build();
builder 把这些组件收进一个上下文对象(可以理解为 AiServiceContext):模型、流式模型、记忆提供者、检索器、工具、护栏、监听器。
1.3 生成动态代理
build() 返回的是接口的代理对象,不是你的实现类。每次调用接口方法,都会被 InvocationHandler 拦截。
// 你写的
assistant.chat("user-42", "订单到哪了?");
// 代理实际做的
// 1. 解析方法签名与注解
// 2. 组装 SystemMessage / UserMessage
// 3. 读取记忆、检索内容
// 4. 调用 ChatModel
// 5. 解析返回值
1.4 调用模型与解析
如果模型返回的是普通文本,直接解析成 String 或 POJO;如果返回的是工具调用请求,代理会执行工具,把结果回填,再次调用模型,直到得到最终回答或达到工具调用上限。
图 1:AiServices 的一次调用生命周期。工具调用是一个循环,不是一次性分支。
1.5 动态代理的边界:哪些能做,哪些不能
AiServices 基于 JDK 动态代理,因此它继承了这个机制的能力与限制。
| 能力 | 支持情况 | 建议 |
|---|---|---|
| 接口 | ✅ | 用接口定义 AI Service |
| 抽象类 | ❌ | 改成接口 |
| 默认方法 | ⚠️ | 不要放业务逻辑,容易被误当成 AI 方法 |
| 方法重载 | ⚠️ | 一个业务动作一个方法,避免歧义 |
| 泛型返回 | ⚠️ | 用具体类型,别依赖类型擦除后的推断 |
toString / equals | ⚠️ | 不要依赖代理的默认实现 |
| 静态方法 | ❌ | 代理无法拦截 |
三个直接结论:
- 接口就是契约:方法名、参数、注解、返回类型共同决定 Prompt 与解析方式;
- 不要用继承复用 AI Service:父接口的
@SystemMessage不会被子接口自动继承; - 默认方法要谨慎:它可能被代理当成普通 AI 方法处理,行为不符合预期。
接口化还有一个好处:测试时可以直接实现接口做 Fake,不需要启动模型。
1.6 为什么是接口,而不是继承
AI Service 选择“接口 + 代理”,而不是“继承基类”,有三个工程原因:
- 组合优于继承:你可以把同一个接口装配成不同模型、不同记忆、不同检索器的实例;
- 测试友好:测试时直接实现接口做 Fake,不需要启动模型;
- 边界清晰:接口只暴露业务动作,不暴露框架内部状态。
代价是:接口不能有状态、不能有构造器注入、不能靠父类复用。解决办法是把状态放在 ChatMemory/Store,把复用放在组合根,而不是塞进接口。
2. 装配顺序:先什么,后什么
| 顺序 | 阶段 | 输入 | 输出 | 失败表现 |
|---|---|---|---|---|
| 1 | System message | 注解 / Provider | SystemMessage | 人设不生效 |
| 2 | User message | 方法参数 / 模板 | UserMessage | 变量为空、模板报错 |
| 3 | Memory | @MemoryId + Store | 历史消息 | 失忆、串号 |
| 4 | RAG | Query + Retriever | 检索内容 | 答非所问 |
| 5 | Tools | @Tool 方法 | 工具规格 | 工具不触发 |
| 6 | Model | ChatRequest | ChatResponse | 超时、限流 |
| 7 | Output parser | 模型文本 | String / POJO | 解析失败 |
2.1 System message 先于一切
System message 决定角色、边界与输出格式。它来自三种来源,优先级从高到低:
- 方法上的
@SystemMessage; - 接口上的
@SystemMessage; systemMessageProvider动态生成。
如果用了 systemMessageProvider,还可以按 memoryId 给不同租户不同的系统提示。
2.2 User message 与模板变量
@UserMessage 支持 {{变量}},配合 @V 绑定参数:
@UserMessage("请用 {{style}} 的风格回答:{{question}}")
String chat(@V("style") String style,
@V("question") String question);
模板错误通常在启动后第一次调用才暴露,建议为每个 Prompt 写一个契约测试(A15)。
2.3 记忆:按 @MemoryId 隔离
chatMemoryProvider 收到的是方法上 @MemoryId 的值;没有该注解时,默认值是字符串 "default"。
.chatMemoryProvider(id -> MessageWindowChatMemory.withMaxMessages(20))
两个关键约束:
- 同一个
@MemoryId不应并发调用,否则可能损坏记忆;框架不负责加锁。 - 记忆不会自动清理,长会话要设 TTL 或显式
evictChatMemory。
2.4 RAG:检索内容在模型调用前注入
contentRetriever 会在每次调用时检索,并把内容注入到请求中。注入位置由 ContentInjector 决定;默认实现通常把内容放到用户消息附近。
如果你不想每个请求都检索,可以把 ContentRetriever 包装成 @Tool,让模型自己决定是否检索。这两种方式的成本与召回行为不同:
| 方式 | 时机 | 优点 | 缺点 |
|---|---|---|---|
contentRetriever | 每次调用 | 稳定、实现简单 | 简单问题也会检索 |
| RAG as Tool | 模型决定 | 省检索成本 | 依赖模型判断,可能漏检 |
2.5 工具:在模型返回后执行
工具不是“提前执行”,而是模型返回 ToolExecutionRequest 后才执行。执行结果以 ToolExecutionResultMessage 回填,再调用模型。
这解释了一个常见现象:工具没被调用,不是工具代码有问题,而是模型没有选择它。 优先检查:
- 工具是否注册;
- 工具描述是否清晰;
- 参数 schema 是否可解析;
- 模型是否支持 function calling;
- 系统提示是否明确要求使用工具。
2.6 输出解析:最后一步,也最容易失败
String 返回最简单;POJO 返回需要模型输出可解析的 JSON。常见失败原因:
- Prompt 没有明确要求“只输出 JSON”;
- 模型在 JSON 前后加了说明文字;
- 字段类型不匹配;
- 嵌套结构过于复杂。
修复顺序:先收紧 Prompt,再简化 DTO,最后才考虑自定义输出解析器。
2.7 结构化输出实战:从 Prompt 到 POJO
结构化输出的目标不是“让模型输出 JSON”,而是“让下游拿到可校验的对象”。
public record Answer(
String conclusion,
List<String> evidence,
double confidence) {}
interface Analyst {
@SystemMessage("""
你是严谨的技术分析师。
只输出 JSON,不要 Markdown 代码块,不要额外解释。
JSON 结构:
{"conclusion":"...","evidence":["..."],"confidence":0.0}
confidence 取 0 到 1 之间的小数。
""")
Answer analyze(@UserMessage String question);
}
四个提高成功率的做法:
- 字段越少越稳:先 3–5 个扁平字段,不要一上来就嵌套;
- 给完整示例:示例比“请输出 JSON”有效;
- 禁止额外文本:明确“不要 Markdown 代码块、不要解释”;
- 失败可重试:第一次解析失败,用更严格的 Prompt 重试一次,再走兜底。
仍然要记住:POJO 返回不是契约,只是解析结果。 模型可能返回多余字段、类型错误或缺失字段,生产代码要校验,不能直接信任。
2.8 记忆与 RAG 的先后顺序
装配顺序会影响最终 ChatRequest 的消息结构:
SystemMessage
→ 历史消息(ChatMemory)
→ 检索内容(RAG)
→ 当前 UserMessage
三个实践建议:
- RAG 内容不要写回记忆:检索结果是“本次上下文”,不是“对话历史”,写回会导致下轮重复注入;
- 历史消息要裁剪:RAG 已经占掉大量 Token,记忆窗口要相应缩小;
- 用 Listener 看最终请求:不要凭想象推断顺序,直接打印脱敏后的消息角色与长度。
顺序本身没有绝对对错,但必须与你的 Prompt 设计一致。如果 SystemMessage 说“只依据提供的资料回答”,而检索内容排在历史消息之前,模型可能优先参考历史。
3. 返回类型:不同返回类型走不同路径
| 返回类型 | 执行方式 | 适用场景 | 注意 |
|---|---|---|---|
String | 阻塞 | 简单问答 | 最稳定 |
POJO / Result<T> | 阻塞 + 解析 | 结构化输出 | 需要 JSON 约束 |
TokenStream | 回调流式 | SSE、打字机效果 | 需要 StreamingChatModel |
CompletableFuture<T> | 非阻塞 | 高并发、异步编排 | 需要模型支持异步 |
Flow.Publisher<T> | 响应式流 | WebFlux、背压 | 需要响应式适配 |
1.20 之后,LangChain4j 用“返回类型驱动执行模型”。选错返回类型,会在吞吐、延迟与可取消性上付出代价(A05 详述)。
3.1 返回类型选择的三个反例
反例 1:要 POJO,但 Prompt 没约束。
结果:模型输出“好的,以下是 JSON:…”,解析失败。修复:收紧 Prompt,只输出 JSON。
反例 2:返回 String,却在下游用正则抠字段。
结果:模型换个措辞,正则就崩。修复:需要结构化就用 POJO,并在服务端校验。
反例 3:返回 TokenStream,但业务要完整 JSON。
结果:流式片段无法直接反序列化。修复:流式只用于展示,完整对象在 onCompleteResponse 后解析。
结论:返回类型由消费端决定,不由模型决定。
4. 一次带工具调用的调试示例
// LangChain4j 1.23.0 | JDK 21
interface SupportAssistant {
@SystemMessage("""
你是企业客服助手。
查询订单必须调用 queryOrderStatus,不要凭记忆回答。
""")
String chat(@MemoryId String conversationId,
@UserMessage String question);
}
public static class OrderTools {
@Tool("查询订单状态。只读操作,不会修改订单。")
public String queryOrderStatus(@P("订单号") String orderId) {
return switch (orderId) {
case "SO-2026-0001" -> "已发货,预计 2026-11-07 送达";
default -> "未找到订单:" + orderId;
};
}
}
SupportAssistant assistant = AiServices.builder(SupportAssistant.class)
.chatModel(chatModel)
.chatMemoryProvider(id -> MessageWindowChatMemory.withMaxMessages(20))
.tools(new OrderTools())
.build();
String answer = assistant.chat("user-42", "订单 SO-2026-0001 到哪了?");
如果工具没有被调用,按这个顺序排查:
1. 去掉 SystemMessage,看是否触发
2. 把工具描述改得更具体,看是否触发
3. 把参数改成 String,看 schema 是否问题
4. 换一个支持 function calling 的模型
5. 打开 ChatModelListener,确认请求里是否包含 tools
4.1 工具调用循环的终止条件
工具调用是一个循环:模型请求工具 → 执行 → 回填 → 再请求模型。没有终止条件,就会变成“调用—失败—再调用”的烧钱循环。
| 终止条件 | 建议阈值 | 行为 |
|---|---|---|
| 最大轮次 | 3–5 轮 | 返回“需要人工确认” |
| 相同调用重复 | 同一工具 + 同一参数出现 2 次 | 停止并提示模型换方法 |
| 连续失败 | 2–3 次 | 兜底回答 + 告警 |
| 时间预算 | 单请求 30–60s | 中断并返回部分结果 |
| Token 预算 | 按业务设定 | 中断并降级 |
// 伪代码:在工具执行包装层做终止判断
if (round++ >= maxRounds) {
return "工具调用轮次超限,请人工确认。";
}
if (seenCalls.contains(toolName + argsHash)) {
return "检测到重复工具调用,已停止。";
}
终止后不要直接抛异常,要给用户一个可读的降级回答;同时把终止原因写入日志与指标,方便定位是描述问题、参数问题还是模型能力问题。
4.2 工具调用日志应该记什么
| 字段 | 用途 | 注意 |
|---|---|---|
| 工具名 | 定位哪个工具 | 无 |
| 参数哈希 | 检测重复调用 | 不记录明文敏感参数 |
| 轮次 | 判断是否循环 | 无 |
| 耗时 | 定位慢工具 | 无 |
| 结果状态 | 成功/失败/待审批 | 不记录完整返回体 |
| 错误码 | 分类统计 | 无 |
| 模型 invocationId | 串联一次对话 | 无 |
没有这些字段,线上只能看到“模型答错了”,无法判断是工具没调用、调用失败,还是结果没被采纳。
5. 常见失败模式
| 现象 | 最可能的层 | 排查动作 |
|---|---|---|
| 工具不触发 | L5 / L6 | 看 ChatRequest 是否带 tools;看描述与参数 |
| POJO 解析失败 | L2 / L7 | 收紧 Prompt;检查 DTO;看原始输出 |
| 记忆不生效 | L3 | 确认 @MemoryId 与 provider;确认 Store 读写 |
| RAG 没用到 | L4 | 看检索结果是否为空;看注入位置 |
| 流式不流 | L1 / L6 | 确认用了 StreamingChatModel;确认返回类型 |
| 同一会话错乱 | L3 | 检查是否并发调用同一个 @MemoryId |
5.1 一个排查案例:工具为什么没触发
现象:OrderTools 已注册,问“订单 SO-2026-0001 到哪了”,模型却回答“请提供订单号”。
按五步排查,不要一上来就换模型:
| 步骤 | 观察点 | 常见问题 | 修复 |
|---|---|---|---|
| 1. 是否注册 | ChatRequest.tools() | 忘记 .tools(...) | 注册工具 |
| 2. 描述是否清晰 | 工具描述文本 | 只写“查询订单” | 写清场景、参数、只读性 |
| 3. schema 是否可解析 | 参数类型与注解 | 参数是复杂对象/泛型 | 改成 String/基本类型 |
| 4. 模型是否支持 | 模型能力说明 | 选了不支持 function calling 的模型 | 换模型或改用文本解析 |
| 5. 系统提示是否冲突 | SystemMessage | 写了“不要调用外部工具” | 明确要求使用工具 |
这个顺序的价值是:先排除工程问题,再怀疑模型。 大多数“工具不触发”死在 2 和 3。
6. 源码阅读地图
建议按这个顺序读 1.23.0 源码:
AiServices:builder 与入口;AiServiceContext:装配了哪些组件;- 动态代理与
InvocationHandler:方法调用如何被拦截; ServiceOutputParser:返回值如何解析;- 工具执行相关类:
ToolService/ToolExecutor; DefaultRetrievalAugmentor:RAG 如何织入。
内部类名会随版本变化,读源码时以目标 tag 为准;本文关注的是调用顺序与职责边界,不是某个类的实现细节。
6.1 读源码的三条线索
- 入口线:从
AiServices.builder(...).build()进入,跟到代理创建; - 装配线:看上下文对象收了哪些组件,以及默认值是什么;
- 调用线:在动态代理的
invoke打断点,观察方法签名如何变成消息与工具请求。
不要从工具类、解析类开始读,容易迷路。先建立“入口 → 装配 → 调用”的主干,再按问题下钻。
7. FAQ
AiServices 生成的代理是线程安全的吗?
代理本身基本无状态,但记忆不是:同一个 @MemoryId 并发调用可能损坏 ChatMemory。生产上要按会话串行化,或使用带锁的 ChatMemory 包装。
接口方法可以重载吗?
可以定义多个方法,但建议一个业务动作一个方法;重载会增加 Prompt 与参数解析的歧义。
为什么结构化输出偶尔失败?
模型输出不是严格 JSON 是常态。用清晰的 schema、低 temperature、只输出 JSON 的指令,并准备重试/兜底解析。
工具调用有次数上限吗?
生产上要显式设置工具调用上限(以及单次请求的超时与预算),避免模型陷入“调用—失败—再调用”的循环。具体配置项以 1.23.0 实际 API 为准。
如何看到实际发送的 ChatRequest?
配置 ChatModelListener,在 onRequest 中记录消息、工具、参数;注意脱敏,不要把完整用户数据写日志。
为什么工具日志不能记录完整参数?
订单号、手机号、金额都可能是敏感信息。日志里记录参数哈希或掩码,排错时用 traceId 去受控审计库查完整值,既保留可追溯性,又避免泄露。
7.1 什么时候不该用 AiServices
- 只调用一次模型、无记忆/RAG/工具:直接用
ChatModel更透明; - 需要完全控制消息结构与工具循环:用低层 API;
- 返回结构极其复杂、需要严格 schema 校验:低层 API + 显式 JSON Schema 更稳;
- 团队还没理解装配顺序:先用低层 API 写一遍,再上 AiServices。
AiServices 是“装配器”,不是“万能层”。它的价值在组合,不在单次调用。
8. 速查表
| 阶段 | 关键点 | 排查入口 |
|---|---|---|
| System | 优先级:方法 > 接口 > Provider | 看最终 SystemMessage |
| User | @V 变量绑定 | 看模板渲染结果 |
| Memory | @MemoryId 隔离 | 看 Store key 与并发 |
| RAG | 检索时机与注入位置 | 看 Top-K 与内容 |
| Tool | 模型选择后才执行 | 看 ChatRequest.tools |
| Model | 超时、限流、能力 | 看 Listener 与指标 |
| Parser | JSON 约束 | 看原始输出 |
9. 系列导航
- 上一篇:A02《LangChain4j 生产级工程骨架:12 个模块 + Docker 一键启动》
- 下一篇:A04《ChatMemory 上下文工程:6 种记忆策略与 Redis 持久化》
- 全系列:《LangChain4j 生产级实战》20 篇路线图(专栏内查看)
更多推荐



所有评论(0)