AiServices 源码级拆解:一个接口如何变成 RAG Agent

一句话结论:AiServices 不是魔法,而是“动态代理 + 上下文装配 + 消息构建 + 输出解析”的运行时。理解装配顺序,才能定位 90% 的“工具不触发、结构化输出失败、记忆不生效”。

适用版本:LangChain4j 1.23.0 / JDK 21
运行方式:内存模式 + Mock,无需 Docker / PgVector / Redis


0. 先说结论

先看三个真实问题:

  • 加了 @Tool,模型就是不调用;
  • 返回 POJO 偶尔解析失败;
  • 加了 chatMemoryProvider,多轮对话还是“失忆”。

它们看起来是三个问题,实际上都指向同一件事:你没有按 AiServices 的装配顺序去理解它。

本文按一次方法调用的生命周期拆开:

  1. 接口如何变成代理;
  2. 代理如何把方法参数变成 ChatRequest;
  3. 记忆、RAG、工具在什么时机被织入;
  4. 返回值如何被解析;
  5. 出问题时,应该先看哪一层。

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;如果返回的是工具调用请求,代理会执行工具,把结果回填,再次调用模型,直到得到最终回答或达到工具调用上限。

是

否

assistant.chat(id, question)

动态代理
解析方法签名与注解

装配上下文
System / User / Memory / RAG

ChatModel.chat(ChatRequest)

返回工具调用?

执行工具
回填 ToolExecutionResult

解析返回值
String / POJO / TokenStream

图 1:AiServices 的一次调用生命周期。工具调用是一个循环,不是一次性分支。


1.5 动态代理的边界:哪些能做,哪些不能

AiServices 基于 JDK 动态代理,因此它继承了这个机制的能力与限制。

能力支持情况建议
接口✅用接口定义 AI Service
抽象类❌改成接口
默认方法⚠️不要放业务逻辑,容易被误当成 AI 方法
方法重载⚠️一个业务动作一个方法,避免歧义
泛型返回⚠️用具体类型,别依赖类型擦除后的推断
toString / equals⚠️不要依赖代理的默认实现
静态方法❌代理无法拦截

三个直接结论:

  1. 接口就是契约:方法名、参数、注解、返回类型共同决定 Prompt 与解析方式;
  2. 不要用继承复用 AI Service:父接口的 @SystemMessage 不会被子接口自动继承;
  3. 默认方法要谨慎:它可能被代理当成普通 AI 方法处理,行为不符合预期。

接口化还有一个好处:测试时可以直接实现接口做 Fake,不需要启动模型。


1.6 为什么是接口,而不是继承

AI Service 选择“接口 + 代理”,而不是“继承基类”,有三个工程原因:

  1. 组合优于继承:你可以把同一个接口装配成不同模型、不同记忆、不同检索器的实例;
  2. 测试友好:测试时直接实现接口做 Fake,不需要启动模型;
  3. 边界清晰:接口只暴露业务动作,不暴露框架内部状态。

代价是:接口不能有状态、不能有构造器注入、不能靠父类复用。解决办法是把状态放在 ChatMemory/Store,把复用放在组合根,而不是塞进接口。


2. 装配顺序:先什么,后什么

顺序阶段输入输出失败表现
1System message注解 / ProviderSystemMessage人设不生效
2User message方法参数 / 模板UserMessage变量为空、模板报错
3Memory@MemoryId + Store历史消息失忆、串号
4RAGQuery + Retriever检索内容答非所问
5Tools@Tool 方法工具规格工具不触发
6ModelChatRequestChatResponse超时、限流
7Output parser模型文本String / POJO解析失败

2.1 System message 先于一切

System message 决定角色、边界与输出格式。它来自三种来源,优先级从高到低:

  1. 方法上的 @SystemMessage;
  2. 接口上的 @SystemMessage;
  3. 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 回填,再调用模型。

这解释了一个常见现象:工具没被调用,不是工具代码有问题,而是模型没有选择它。 优先检查:

  1. 工具是否注册;
  2. 工具描述是否清晰;
  3. 参数 schema 是否可解析;
  4. 模型是否支持 function calling;
  5. 系统提示是否明确要求使用工具。

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);
}

四个提高成功率的做法:

  1. 字段越少越稳:先 3–5 个扁平字段,不要一上来就嵌套;
  2. 给完整示例:示例比“请输出 JSON”有效;
  3. 禁止额外文本:明确“不要 Markdown 代码块、不要解释”;
  4. 失败可重试:第一次解析失败,用更严格的 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 源码:

  1. AiServices:builder 与入口;
  2. AiServiceContext:装配了哪些组件;
  3. 动态代理与 InvocationHandler:方法调用如何被拦截;
  4. ServiceOutputParser:返回值如何解析;
  5. 工具执行相关类:ToolService / ToolExecutor;
  6. DefaultRetrievalAugmentor:RAG 如何织入。

内部类名会随版本变化,读源码时以目标 tag 为准;本文关注的是调用顺序与职责边界,不是某个类的实现细节。


6.1 读源码的三条线索

  1. 入口线:从 AiServices.builder(...).build() 进入,跟到代理创建;
  2. 装配线:看上下文对象收了哪些组件,以及默认值是什么;
  3. 调用线:在动态代理的 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 与指标
ParserJSON 约束看原始输出

9. 系列导航

  • 上一篇:A02《LangChain4j 生产级工程骨架:12 个模块 + Docker 一键启动》
  • 下一篇:A04《ChatMemory 上下文工程:6 种记忆策略与 Redis 持久化》
  • 全系列:《LangChain4j 生产级实战》20 篇路线图(专栏内查看)

Logo

更多推荐