【LangChain4J-09】开发学习知识点

LangChain4J(9) 开发学习知识点

本文档基于当前项目 langchain4j-springboot-demo + langchain4j-system(包名 com.example.langchain4j / com.example.langchain4j.system)的真实实现整理,覆盖大模型 API 参数、Spring Boot 整合、Tool、Prompt 工程、RAG、Agent 与本项目优化设计,并附面试题与回答。重点内容加粗并标注颜色:红色=风险/易错绿色=最佳实践蓝色=类/接口/API 名紫色=注解


【一】大模型 API 参数

【1】整体入参与出参结构

(1)请求体(Request)整体结构

一次 Chat Completions 调用本质是一个 JSON POST,核心字段如下:

字段 类型 含义
model string 模型标识,如 deepseek-chatqwen-plusgpt-4o
messages array 对话历史,每条含 role + content
temperature float 采样温度,控制随机性
top_p float 核采样,控制候选词累积概率
max_tokens / max_completion_tokens int 输出上限
stream bool 是否流式返回
response_format object 输出格式约束(如 JSON)
tools / tool_choice array/string 函数调用声明
seed int 随机种子,保证可复现
stop array 停止符
frequency_penalty / presence_penalty float 重复惩罚
n int 一次返回几条候选
user string 终端用户标识(安全/审计)
logit_bias / logprobs object/bool 词表偏置 / 对数概率

messages 数组的 role 取值system(系统指令)、user(用户)、assistant(模型)、tool(工具返回结果)。很多人误以为只有一个 prompt 字段------Chat 接口从来都是 messages 数组,没有单独的 prompt 字段

(2)响应体(Response)整体结构
字段 含义
id 本次请求 ID
model 实际服务模型
choices[].message 模型返回消息(role + content,或 tool_calls)
choices[].finish_reason 结束原因
usage.prompt_tokens 输入 token
usage.completion_tokens 输出 token
usage.total_tokens 合计 token
usage.prompt_tokens_details.cached_tokens 命中缓存的输入 token(部分厂商)
usage.completion_tokens_details.reasoning_tokens 推理模型的思考 token
system_fingerprint 后端系统指纹,同指纹下行为一致

finish_reason 的四个取值必须分清

  • stop:正常生成完毕;
  • length达到 max_tokens 被截断(答案不完整,需调大上限或分段);
  • tool_calls:模型决定调用工具(此时 message 里带 tool_calls);
  • content_filter:被安全策略拦截。

本项目 sys_llm_call 表已留存 prompt_tokens/completion_tokens/total_tokens/reasoning_tokens 四个字段,正是这套 usage 结构的落地。

【2】核心参数逐条详解

(1)model ------ 选哪个模型

决定能力、价格、上下文长度与是否支持工具/视觉。生产环境建议按场景路由不同模型 :简单分类用小模型省钱,复杂推理用大模型。本项目用 LlmClientFactorycode 解析出 ChatLanguageModelLlmModelCategory 区分 LLM / EMBEDDING / RERANK / VL。

(2)messages / role ------ 多轮上下文的载体

system 放人设与规则(最前面、最稳定);user 放本轮问题;assistant 放历史回答;tool 放工具结果。顺序即上下文顺序,前缀越稳定,厂商 KV 缓存命中率越高(见【四】缓存章节)。

(3)temperature ------ 随机性开关

范围通常 0~2,默认 1。0 最确定、1 较发散 。SQL 生成、分类、抽取用 0~0.3;创意写作用 0.7~1.0。本项目参数模板 ParamTemplateCatalog 按模型固化了合理默认值。

(4)top_p ------ 核采样

只从累积概率达 top_p 的最小词集合中采样。与 temperature 作用重叠,二者不要同时大幅调,否则行为难以解释 ,一般固定 top_p=1 只调 temperature,或固定 temperature 只调 top_p。

(5)top_k(部分厂商)

在概率最高的 k 个词里采样,比 top_p 更"硬"。OpenAI 不直接暴露,DeepSeek/Kimi 等部分支持。

(6)max_tokens / max_completion_tokens ------ 输出上限

只限制输出、不限制输入 ;输入超限会直接报错(见【二】上下文裁剪)。推理模型(o 系列、deepseek-reasoner)用 max_completion_tokens 表示"思考+回答"总预算,且思考 token 也计费。本项目 first_token_ms 埋点即监控首 token 延迟,避免推理模型思考过久。

(7)n ------ 候选数

一次返回 n 条独立回答。n>1 时 token 消耗乘以 n,仅在需要多样性(如摘要候选)时使用。

(8)stop ------ 停止符

遇到即停,用于约束输出边界(如代码块结束符)。一般用 response_format 替代更稳。

(9)stream / stream_options ------ 流式

stream=true 时按 SSE 逐块返回,stream_options.include_usage=true 可在末块拿到 usage。本项目 LlmStreamHandler + StreamCallback 即消费该流,先吐首包再逐字,配合 first_token_ms 计算首 token 延迟。

(10)response_format ------ 结构化输出

{"type":"json_object"}{"type":"json_schema","json_schema":{...}}要让模型稳定吐 JSON,首选 json_schema 模式 (函数调用/Pojo 提取同理)。本项目智能体强制只输出 {category,confidence,reason} 即靠此约束。

(11)seed ------ 可复现

固定 seed + 固定 temperature,相同输入可得近似相同输出,用于回归测试与效果比对。注意不同后端对 seed 支持程度不同。

(12)frequency_penalty / presence_penalty ------ 重复惩罚

都取 -2~2。frequency 按词出现次数惩罚(越重复越压),presence 按词是否出现过惩罚(出现过就压)。模型复读严重(尤其长文本)时调到 0.2~0.5

(13)logit_bias / logprobs ------ 词表偏置与概率

logit_bias 强行抬升/压低某些 token 概率(如禁止某词);logprobs=true 返回每个位置 Top 候选概率,用于调试与可控生成。日常少用。

(14)user ------ 用户标识

终端用户 ID,用于安全审计、限流、内容溯源。生产必带

(15)tools / tool_choice ------ 函数调用

tools 声明可用工具(ToolSpecification),tool_choice 取值 auto/none/指定工具名。模型返回 tool_calls 而非直接回答,由你执行后回灌结果(详见【三】)。

(16)reasoning_effort / thinking ------ 推理预算

OpenAI o 系列 reasoning_effort(low/medium/high),DeepSeek-R1 thinking开启推理会显著增加延迟与 token 成本,简单任务应关掉。

(17)缓存相关出参 ------ cached_tokens

OpenAI/DeepSeek 在 usage.prompt_tokens_details.cached_tokens 返回命中前缀缓存的输入 token,这部分按折扣计费(约 1/10)。这是降本核心指标,也是【四】缓存章节的落点。

【3】参数使用案例(难度递进)

以下案例统一用 LangChain4J 命令式写法(ChatLanguageModel),问题均为中文。

(1)案例一:最基础的单轮问答(只用必填项)
java 复制代码
ChatLanguageModel model = LlmClientFactory.get("deepseek-chat");
String answer = model.generate("用一句话解释什么是向量数据库");
System.out.println(answer);

递进原因:先建立"模型 = 函数(问题)→回答"的直觉,此时隐藏了所有采样参数(走厂商默认 temperature=1)。

(2)案例二:加 temperature 控制创意度
java 复制代码
String poem = model.generate(
    "写一首关于秋天的五言绝句,要求押韵");
// 想要更确定/更工稳,可换用参数绑定:
ChatRequest req = ChatRequest.builder()
    .messages(UserMessage.from("写一首关于秋天的五言绝句"))
    .parameters(ChatRequestParameters.builder().temperature(0.3).build())
    .build();

递进原因:temperature 是"最该先理解"的采样参数;创作类任务对比 0.3 vs 1.0 输出差异最直观。

(3)案例三:加 system 角色 + 结构化输出
java 复制代码
UserMessage u = UserMessage.from("把'苹果手机很贵'归类为:数码/食品/其他");
SystemMessage s = SystemMessage.from("你是分类器,只输出 JSON:{\"category\":\"\",\"confidence\":0.0}");
ChatRequestParameters p = ChatRequestParameters.builder()
    .temperature(0.1)
    .responseFormat(ResponseFormat.builder()
        .type(ResponseFormatType.JSON)
        .jsonSchema(/* 声明 category/confidence 字段 */).build())
    .build();

递进原因 :引入 system 承载人设/规则(稳定前缀,利于缓存),用 response_format 把"自由文本"变成"可解析结构",这是工程化的关键一跃。

(4)案例四:加 stream 流式输出(首包优先)
java 复制代码
TokenStream stream = model.generate(UserMessage.from("讲讲 RAG 的原理"));
stream.onNext(tok -> System.out.print(tok));        // 逐字
       .onComplete(r -> System.out.println("\n耗时:"+r.tokenUsage()));
       .onError(e -> log.error("流异常", e))
       .start();

递进原因 :真实产品的"打字机效果"与超时控制都依赖流;顺势引出 first_token_ms、流式乱码等生产问题。

(5)案例五:加 tools 函数调用
java 复制代码
// 声明工具 → 模型返回 tool_calls → 执行 → 回灌(见【三】完整链路)

递进原因:模型从"只会答"进化到"会办事",引出工具协议与防循环。

(6)案例六:加 reasoning 推理模型
java 复制代码
ChatLanguageModel reasoner = LlmClientFactory.get("deepseek-reasoner");
// 注意:回答前会先产生 reasoning_tokens,首 token 延迟更高

递进原因 :展示 reasoning_tokens 对成本/延迟的影响,呼应【2】第(16)条。

【4】对应 Spring Boot 方法案例(注解 + 出入参)

用声明式 AiServices 把上述参数固化进接口:

java 复制代码
@AiService
public interface Assistant {
    // @SystemMessage 对应 system 角色(稳定前缀)
    @SystemMessage("你是严谨的中文分类器,只输出 JSON")
    // @UserMessage 对应 user 角色;@V 注入变量
    @UserMessage("把下面文本分类:{{text}},目标类别:{{targets}}")
    // @MemoryId 按会话隔离多轮记忆
    String classify(@MemoryId String sessionId,
                    @V("text") String text,
                    @V("targets") List<String> targets);
}

注解作用对照

注解 对应 API 概念 作用
@SystemMessage system 消息 人设/规则,放最前
@UserMessage + {``{var}} user 消息模板 动态问题
@V("x") 变量绑定 把方法参数塞进模板
@MemoryId 会话 key 多轮记忆隔离
@Tool tools 声明 函数调用
@Moderate 内容审核 输入输出合规

出参 :声明式方法直接返回 String/POJO/TokenStream(流式),底层 ChatResponse 里的 tokenUsage()finishReason() 可通过 ChatModelListener 拿到,正是本项目 token 埋点的接入点。

【5】参数调优方法论

  • 确定性任务(分类/抽取/SQL)→ 低温 + JSON 约束:temperature 0~0.3,固定 seed 做回归。
  • 创意任务 → 高温度:0.7~1.0,接受不确定性。
  • temperature 与 top_p 二选一调:避免叠加不可解释。
  • 复读严重 → 加 penalty:0.2~0.5。
  • 成本敏感 → 控 max_tokens + 提缓存命中:见【四】前缀缓存。
  • 长输出被截断(finish_reason=length)→ 分段或调大上限
  • 可复现验证 → seed + 冻结模型版本(system_fingerprint)

【6】常见错误与限流

  • 401 :API Key 缺失/过期 → 检查配置(LlmClientFactory 的 key 来源)。
  • 429触发限流,本项目对话中途曾遇 429;必须指数退避重试,且区分"可重试"(429/5xx)与"不可重试"(400/401)。
  • 400:参数非法(如 json_schema 写错)。
  • 500/503:后端抖动,重试即可。

【7】面试题与回答

Q1:temperature 和 top_p 有什么区别?生产怎么选?

A:temperature 缩放 logits 后再采样,top_p 在累积概率阈值内截断候选集。二者都控随机性,建议二选一调,避免叠加难解释。确定性任务低温,创意任务高温。

Q2:回答被截断了(finish_reason=length)说明什么?怎么处理?

A:达到 max_tokens 上限。处理:调大上限、或把任务分段、或对长文用流式并前端拼接。

Q3:cached_tokens 是什么?为什么重要?

A:前缀缓存命中的输入 token,按折扣(约 1/10)计费。提升它是降本核心,手段是"稳定前缀放最前 + 输出/易变内容放最后"(见【四】)。

Q4:reasoning 模型为什么又慢又贵?

A:先产生 reasoning_tokens(思考过程)再出答案,思考 token 同样计费且拉长首 token 延迟,简单任务应关闭。


【二】Spring Boot 整合 LangChain4J

【1】整合方式与核心注解

(1)声明式(@AiService)------ 高层入口
java 复制代码
@AiService
public interface ChatAssistant {
    @SystemMessage(fromResource = "prompts/system.txt")
    String chat(@MemoryId String sessionId, @UserMessage String userMessage);
}

常用注解全表

注解 功能
@AiService dev.langchain4j.service.spring 标记接口为 AI 服务,启动扫描生成代理
@SystemMessage dev.langchain4j.service system 消息(value 或 fromResource)
@UserMessage 同上 user 消息模板,{``{var}} 占位
@V("x") 同上 方法参数 → 模板变量
@MemoryId 同上 记忆隔离 key
@Tool dev.langchain4j.service.tool 标记方法为可调用工具
@P("x") 同上 工具参数描述
@Moderate dev.langchain4j.service 内容审核
@Recipient 同上 多模型路由(指定由哪个模型回答)
@StructuredPrompt + @Description dev.langchain4j.service POJO 提取

记忆接线提醒 :要让 @MemoryId 生效,上下文须有 ChatMemoryProvider Bean(本项目 ChatMemoryConfig 提供),否则声明式服务默认无记忆。

(2)命令式(不用注解)------ 等价代码

声明式底层仍是 ChatModel,命令式自己拼:

java 复制代码
// 等价于 @AiService + @SystemMessage + @MemoryId + @UserMessage
ChatLanguageModel model = LlmClientFactory.get("deepseek-chat");
ChatMemory memory = memoryProvider.get(sessionId);   // @MemoryId 的本质
memory.add(UserMessage.from(req));                    // 注入历史
ChatResponse r = model.generate(memory.messages());
memory.add(r.aiMessage());
return r.aiMessage().text();

两条路线底层都是 ChatModel / StreamingChatLanguageModel,可混用 :工具调用用 @Tool 省事,核心对话用命令式拿中断能力(本项目即采用命令式 + 手写工具循环)。

【2】带记忆的多轮对话实现

(1)记忆窗口两类(必须分清)
裁剪依据 适用
MessageWindowChatMemory 消息条数 对话轮次少、单条短
TokenWindowChatMemory token 数 长文本、防超限更精准

本项目 ChatMemoryConfigMessageWindowChatMemory.builder().maxMessages(20),由 app.chat.memory-max-messages 控制:只留最近 20 条,更早丢弃 → 上下文有上限 → 避免 Token 超限。

(2)多轮隔离与持久化

ChatMemoryProvidersessionId 为 key 用 ConcurrentHashMap 缓存每个会话的 ChatMemory不同会话互不可见 。本项目进一步把记忆落地到 sys_chat_message 表:

  • ISysChatMessageService.loadMemoryWindow:按 memory_row=1 且按 id 升序返回(ChatMemory 要时间正序);
  • appendMemoryBoundary:写一条 role='boundary' 的行作为"清空记忆"边界。

这是一个比纯内存更生产级的记忆方案:重启不丢历史,且"清空"用边界行语义而非物理删除。

【3】自动上下文裁剪,避免 Token 超限

(1)窗口裁剪(被动)

上面 MessageWindowChatMemory/TokenWindowChatMemory 自动丢弃超窗消息,是第一道防线

(2)主动裁剪(本项目做法)

ChatService 在拼上下文前,只取最近 N 条(buildMemoryContext 取最近 6 条摘要)避免无限增长;长文档走 RAG 检索而非全文塞进 prompt(见【五】)。

(3)超限报错的根因

max_tokens 只限输出;输入超限是模型上下文窗口硬限制 (如 8k/32k/128k),超出直接 400。所以"裁剪"本质是控制输入长度:窗口 + 检索增强 + 必要时摘要压缩。

【4】清空对话记忆的实现

java 复制代码
// ChatService.clearMemory() 注入同一个 memoryProvider
memoryProvider.get(sessionId).clear();   // 内存窗口清空
// 同时落库:写一条 boundary 行,历史查询到该行为止
sysChatMessageService.appendMemoryBoundary(sessionId);

前端"🧹 清空记忆"按钮 → POST /api/chat/clear → 上述逻辑。注意 :LangChain4j 1.x 已移除旧 ChatMemoryAccess.evictChatMemory(),勿再用。

【5】核心类全景与作用

类 / 接口 作用 本项目落点
ChatLanguageModel 同步对话模型 LlmClientFactory.get(code)
StreamingChatLanguageModel 流式对话模型 LlmStreamHandler
UserMessage/SystemMessage/AiMessage/ToolExecutionResultMessage 四类消息 命令式拼装
ChatResponse / TokenStream 响应 / 流 token 埋点
ChatMemory / ChatMemoryProvider 记忆 / 工厂 ChatMemoryConfig
ChatMemoryStore 记忆持久化抽象 sys_chat_message 实现
ToolSpecification / ToolExecutor 工具协议 / 执行器 ToolSchemaBuilder
EmbeddingModel / EmbeddingStore 向量化 / 向量库 EmbeddingClientFactory
ContentRetriever 检索器 RagVectorStoreFactory
ChatModelListener 请求/响应监听 token/成本埋点
AiServices 声明式装配 可选使用

【6】面试题与回答

Q1:@AiService 和命令式 ChatModel 怎么选?

A:简单 CRUD 式对话用 @AiService 省代码;需要精细控制工具循环、流式粒度、可观测时用命令式。底层相同,可混用。

Q2:多轮对话怎么防止 token 超限?

A:两层------窗口记忆(按条数或 token 数自动丢弃旧消息)+ 主动裁剪(只取最近 N 条 / RAG 检索替代全文)。

Q3:ChatMemory 有哪两种?区别?

A:MessageWindowChatMemory 按消息条数,TokenWindowChatMemory 按 token 数,后者对长文本防超限更精准。

Q4:清空记忆在生产怎么落地才不会丢审计?

A:本项目用 boundary 边界行语义------不清物理数据,只标记清空点,历史查询止于此,既满足"用户视角清空"又保留审计轨迹。

Q1:ChatMessage 为什么不是字符串数组?哪几个实现类?

A:因为每条消息要带 role + 内容 +(工具调用时)结构化字段,所以是 List<ChatMessage> 对象数组。实现类:SystemMessage(system)、UserMessage(user)、AiMessage(assistant,可含 toolExecutionRequests)、ToolExecutionResultMessage(tool)。

Q2:AiMessage 在"模型要调工具"和"最终回答"时有什么区别?

A:最终回答时 text() 有值、finish_reason=stop;要调工具时 toolExecutionRequests() 非空、text() 通常为空、finish_reason=tool_calls

Q3:ChatRequest 和 ChatRequestParameters 是什么关系?

A:ChatRequest 是信封(messages + parameters + 工具),ChatRequestParameters 是参数明细(温度 / 上限 / 响应格式 / 工具声明)。后者是接口,默认实现装不下厂商专属参数,专属参数要用对应 *ChatRequestParameters 子类。

Q4:SSE 和 WebSocket 怎么选?

A:只要服务端单向推(流式输出、通知)就选 SSE,简单且浏览器自动重连;需要双向(聊天室、协同编辑)才上 WebSocket。

Q5:SseEmitter 的 onCompletion 为什么必须幂等?

A:它在正常完成和异常结束都会触发,无法从事件本身区分"成功"还是"中断",所以留痕 / 计数逻辑必须用 CAS 之类的幂等保护,否则会重复记一轮。

Q6:为什么 StreamCallback 里只传友好信息、不传原始异常?

A:与全局异常处理器"统一显示'接口报错'"的纪律一致------前端恒展示占位文案,不暴露内部堆栈,避免信息泄露与体验崩坏。

Q7:StreamCallback 和 LlmStreamHandler 有什么不同?

A:LlmStreamHandler 在 llm 客户端层,吞掉 Ollama / DeepSeek 差异,供 AbstractLlmClient 调用;StreamCallback 在业务层,是 Handler / Controller 依赖的契约。两者方法同名,但所处层次不同,中间由 AbstractChatHandler 衔接适配。

【7】问题整理

(1)消息体系:ChatMessage / SystemMessage / UserMessage / AiMessage

LangChain4J 把"发给模型的每一句话"都抽象成 ChatMessage(包 dev.langchain4j.data.message),按 role 分四个实现类:

对应 role 谁产生 说明
SystemMessage system 你(开发者) 人设 / 规则 / 稳定前缀,放数组最前
UserMessage user 用户 / 你拼接 本轮问题或注入的上下文
AiMessage assistant 模型 模型回答;当模型要调工具时,不含文本而含 toolExecutionRequests()
ToolExecutionResultMessage tool 你(工具执行后) 工具返回结果,回灌进 messages 让模型继续

ChatMessage 是接口 ,核心方法 type() 返回 ChatMessageTypetext() 取文本(部分子类才有)。常用工厂:

java 复制代码
SystemMessage s = SystemMessage.from("你是严谨的中文分类器");
UserMessage   u = UserMessage.from("把'苹果手机很贵'分类");
AiMessage     a = AiMessage.from("好的,这是分类结果...");   // 纯文本
// 模型要调工具时,AiMessage 不带 text,而是带 ToolExecutionRequest 列表:
AiMessage toolCall = AiMessage.from(ToolExecutionRequest.builder()
        .id("call_1").name("get_weather").arguments("{\"city\":\"北京\"}").build());

最易踩的坑 :很多人以为 messages 是个字符串数组------不是 。它是 List<ChatMessage>,每个元素都是一个带 role 的对象。本项目 AbstractChatHandler.buildMessages() 返回的正是这个 List<ChatMessage>,最终塞进 ChatRequest.messages(...)(见【2】)。

AiMessage 的双重身份(重点)

  • finish_reason=stopAiMessage.text() 有值,是最终答案;
  • finish_reason=tool_callsAiMessage.toolExecutionRequests() 非空,text() 通常为空。本项目 AbstractLlmClient 在流末检测 state.toolCalls 后回调 onToolCalls(),正是基于这一判断(见【八】阶段四)。
(2)请求信封:ChatRequest 与 ChatRequestParameters

单次对话 = 一个 ChatRequest,它把"消息 + 参数 + 工具 + 响应格式"打包:

java 复制代码
ChatRequest req = ChatRequest.builder()
    .messages(List.of(systemMsg, userMsg, aiMsg))      // List<ChatMessage>
    .parameters(ChatRequestParameters.builder()         // 采样/格式/工具等参数
        .modelName("deepseek-chat")
        .temperature(0.3)
        .maxOutputTokens(2048)
        .responseFormat(ResponseFormat.builder()
            .type(ResponseFormatType.JSON).build())
        .build())
    .build();
ChatResponse resp = model.generate(req);

两个类的分工

  • ChatRequest (包 dev.langchain4j.data.message):信封。只关心 messages + parameters +(可选)toolSpecifications。一次 generate 就是一个 ChatRequest。
  • ChatRequestParameters (包 dev.langchain4j.model.chat.request):信封里的"收件人信息 + 寄送偏好"。它是个接口builder() 返回的是 DefaultChatRequestParameters.builder()。关键字段:
字段 含义
modelName 模型标识
temperature / topP 采样参数
maxOutputTokens 输出上限(只限输出,不限制输入)
stopSequences 停止符
toolSpecifications 工具声明(List<ToolSpecification>
toolChoice auto / none / 指定工具名
responseFormat json_object / json_schema

跨厂商专属参数会被静默丢弃(本项目已踩坑)DefaultChatRequestParameters 只能装 OpenAI 系通用字段。Ollama 的 think、某些厂商的 reasoning_effort 在默认参数对象里没有位置------直接塞进默认对象会被忽略 。本项目根治方案:buildRequestWithCustomParams 按厂商选择参数类型,Ollama 用 OllamaChatRequestParameters、OpenAI 系用 DefaultChatRequestParameters(见【八】阶段二第 3 点)。经验:专属参数必须用对应厂商的 *ChatRequestParameters 子类

(3)记忆:ChatMemory 与 ChatMemoryProvider

多轮对话靠"把历史消息塞回下一条请求"实现,这套机制由 ChatMemory 承载。

ChatMemory(包 dev.langchain4j.memory):一个会话的"消息记事本"。

  • 核心方法:add(ChatMessage)messages()(返回 List<ChatMessage>,直接喂给 ChatRequest)、clear()
  • 两种实现(【二】已讲):MessageWindowChatMemory(按条数 截断)、TokenWindowChatMemory(按 token 数截断);
  • 本项目 ChatMemoryConfigMessageWindowChatMemory.builder().maxMessages(20)

ChatMemoryProvider(包 dev.langchain4j.memory) :按 sessionId(memoryId)工厂式 产出 ChatMemory,并负责缓存/隔离/持久化。

  • 接口只有一句:ChatMemory get(Object memoryId)
  • 多会话隔离:内部通常用 Map<memoryId, ChatMemory>(本项目用 ConcurrentHashMap)保证不同会话互不串;
  • 持久化:本项目 ChatMemoryProvidersys_chat_message 表加载(按 memory_row=1 且 id 升序),并支持 boundary 边界行语义的"清空"(见【二】);
  • 它正是 @MemoryId 注解的底层支撑------声明式 @AiService 方法里的 @MemoryId 最终走到 ChatMemoryProvider.get(sessionId)

记忆与 ChatRequest 的衔接

java 复制代码
ChatMemory memory = memoryProvider.get(sessionId);  // 取本会话记事本
memory.add(UserMessage.from(req));                   // 写本轮问题
ChatResponse r = model.generate(memory.messages());  // 直接把 List<ChatMessage> 当请求
memory.add(r.aiMessage());                           // 写回模型回答

记忆的本质 = "把上 N 轮 AiMessage/UserMessage 自动拼回下一条请求" ,而 messages() 返回的就是标准 List<ChatMessage>,与【1】完全打通。

(4)SseEmitter:Spring 流式对话的"管道"

前面所有内容都是"单次同步调用"。要做出"打字机"效果,需要服务端主动、按字节把 token 推给浏览器 ------这就是 SSE(Server-Sent Events),Spring 用 SseEmitter 封装(org.springframework.web.servlet.mvc.method.annotation.SseEmitter)。

SseEmitter 是什么 :一个代表"一次长连接"的对象。Controller 方法直接 return SseEmitter,Spring 会保持 HTTP 连接打开,直到你 complete()。期间你随时 emitter.send(...) 推一段数据。

SSE vs WebSocket(必须分清)

维度 SSE(SseEmitter) WebSocket
方向 单向(服务 → 客户端) 双向
协议 普通 HTTP(长轮询升级) 独立 ws 协议
重连 浏览器原生自动重连 需自己写
适用 流式输出、通知推送 聊天室、游戏

一个最小流式对话骨架

java 复制代码
@PostMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter chatStream(@RequestBody ChatRequestDTO req) {
    SseEmitter emitter = new SseEmitter(300_000L);   // 超时 = 整个 agentic 循环总预算
    // 异步提交,避免阻塞 HTTP 线程
    CompletableFuture.runAsync(() -> {
        llmClient.stream(model, buildRequest(req),
            new MyStreamHandler(emitter));   // 每来一个 token → emitter.send
    }, executor);
    return emitter;   // 立即把"管道"交给前端
}

SseEmitter 生命周期三钩子(本项目关键代码都在这里)

  • emitter.onTimeout(Runnable):超过构造时给的 timeoutMs 触发。本项目先推一条 error 事件再 complete(),否则前端只会看到"正在生成..."无征兆停住。
  • emitter.onCompletion(Runnable):正常结束或异常结束都会触发,用于清理。本项目用它补记"中断"留痕,但因为它在正常完成也触发,必须用 CAS 幂等保护 (见【5】RecordingStreamCallback.recorded)。
  • emitter.onError(Consumer<Throwable>):连接异常(如客户端断网)。本项目在此 cancelled.set(true) 标记中断。

本项目 SSE 超时语义(易错)sse-timeout-ms(默认 300s)是整个 agentic 工具循环的总时间预算 ,不是单轮。数据分析场景每轮要 prefill 上万 token 的库表结构,5 轮就可能撞线导致前端无提示中断------所以超时取值住 sys_config,按需调大。

推送格式:命名事件emitter.send(SseEmitter.event().name("token").data(text))。本项目定义了 8 类命名事件,前端按事件名注册监听:

事件名 触发 前端动作
token 一个增量 token 追加到回答气泡
reasoning 思考链增量 展示"思考中"折叠区
done 本轮完整结束 收尾、渲染图表卡片
error 出错 / 超时 顶部红条"接口报错"
tool 模型调工具 🔧 工具调用气泡
rag RAG 检索触发 "知识库已检索"标识
query_result SQL 结果 数据 / 图表卡片
insight_result 增强分析 洞察卡片

前端提醒 :标准 EventSource 只支持 GET + 默认 message 事件;本项目用 POST 且带自定义事件名,前端需用 fetch + 手动解析 event: / data: 行(或封装 SSE 客户端)才能消费这些命名事件。

(5)StreamCallback 与 SseStreamCallback:把"流"翻译成"事件"

SseEmitter 只负责"推字节"。但模型客户端(Ollama / DeepSeek)回调上来的事件语义是"token / 完成 / 工具调用 / RAG"------需要一层适配。本项目用三套回调接口分层做这件事

(A)LlmStreamHandler(llm 层,包 com.example.langchain4j.llm) :模型客户端这一侧的统一回调接口,AbstractLlmClient 收到厂商每一行流时调用它:

  • onPartialResponse(String token):增量 token
  • onReasoning(String reasoning):思考链(默认空实现)
  • onComplete(String fullText, Long tokenCount):整段完成
  • onError(String friendlyMessage):出错(友好文案)
  • onToolCall / onRag / onQueryResult / onToolResult:工具 / RAG / 结构化结果(默认空实现)

它的作用是把"Ollama 还是 DeepSeek"的差异吞掉------两个客户端都实现这套接口,业务层不关心底层厂商。

(B)StreamCallback(业务层,包 com.example.langchain4j.chat.handler) :业务侧(Handler / Controller)依赖的回调接口,方法与 LlmStreamHandler 同名同义,但它是业务层契约AbstractChatHandler 只认它。关键设计:回调里只传"友好信息",不传原始异常------与全局异常处理器"统一显示'接口报错'"的纪律一致。

(C)SseStreamCallback(Controller 内私有类,实现 StreamCallback) :把 StreamCallback 的每一个方法翻译成一条 SSE 事件推给前端。即"业务事件 → SSE 字节"的最后一公里:

StreamCallback 方法 SseStreamCallback 动作
onToken(token) emitAnsweremitter.send(event("token").data(token))
onReasoning(s) 思考开关开启才发 event("reasoning")
onComplete(text, tokens) event("done").data({text,tokens})emitter.complete()
onError(msg) event("error").data(msg)emitter.complete()
onToolCall(name,args) 取中文展示名 → event("tool")
onRag(info) event("rag")
onQueryResult(info) / onToolResult event("query_result") / event("insight_result")

三层回调的衔接(一张图)

复制代码
厂商流(Ollama/DeepSeek)
   │  每来一行
   ▼
AbstractLlmClient.doStream  → LlmStreamHandler.onPartialResponse/onToolCall/...
   │  (吞掉厂商差异)
   ▼
AbstractChatHandler  → StreamCallback.onToken/onToolCall/onComplete/...
   │  (业务语义)
   ▼
SseStreamCallback(Controller) → emitter.send(event("token"/"done"/"tool"/...))
   │
   ▼
浏览器逐字渲染

两个极易踩的坑(本项目已用代码根治)

  1. "客户端断开"必须同步置位 cancelledSseStreamCallback.push()emitter.sendIOException / IllegalStateException 时,当场 cancelled.set(true)。若等 emitter.onCompletion 异步置位,线程调度不确定,用户每点一次"停止"历史里就多一条"模型失败",污染失败率统计。这是本项目因果确定性的关键设计。

  2. 中断 vs 模型失败必须分清(幂等 CAS)RecordingStreamCallbackStreamCallback 的装饰器)包在 SseStreamCallback 外层,用 AtomicBoolean recorded 的 CAS 保证一轮只留痕一次。逻辑:

    • onComplete → success;
    • onErrorcancelled=true → 记 cancelled(用户中断)而非 error;
    • 前端断开 / 超时,模型侧再无回调 → 由 emitter.onCompletionrecordAborted() 补记 cancelled;
    • 异步任务自身炸了(连模型都没调上)→ ChatControllerwhenCompleterecordTaskFailed()
      四类收尾对应四种留痕,互不重复,且都不把异常漏给对话链路。

为什么 SseStreamCallback 要在 Controller 层 new,而不是包进 ChatServicechatService.stream()立即返回 的------它只把请求发给模型并注册回调,真正的回答在之后的 llm-http 线程上逐 token 回来。所以"一轮真正结束"只能由回调(onComplete / onError)或 SSE 生命周期(onCompletion)观察到,留痕装饰器必须包在 Controller 这一层。


【三】Tool 工具的使用

【1】实现原理

Tool(函数调用)让模型从"只会说"变成"会办事"。核心误区:模型并不会真的调用你的函数。真实链路是:

  1. 你把所有可用工具的 ToolSpecification(名称 + 描述 + 入参 JSON Schema)随请求发给模型;
  2. 模型只返回 一个 tool_calls(含工具名 + JSON 参数),自身不执行;
  3. 你的代码根据 tool_calls 找到对应方法执行,得到结果;
  4. 把结果包成 ToolExecutionResultMessage 回灌进 messages,再次请求模型;
  5. 模型基于工具结果生成最终自然语言回答(或继续调下一个工具)。

本项目 ToolSchemaBuilder 正是把 DB 里的 SysTool 编成 LangChain4J 可用的 ToolSpecification,再走手写循环(ToolAwareLlmHandler + ToolLoopContext)编排。

【2】如何让模型判断"何时用哪个工具"

模型靠描述决策,不是靠代码逻辑。两个关键点:

  • 工具描述要写清触发条件@Tool("查询指定城市的当前天气") 里的文字直接决定模型是否调用。描述模糊 → 模型乱用或不用。
  • 参数描述(@P)要写清格式 :模型按 @P("城市名,如'北京'") 决定传什么。

声明式:

java 复制代码
@Component
public class WeatherTools {
    @Tool("查询指定城市的当前天气,当用户问天气时调用")
    public String weather(@P("城市名,如'北京'") String city) {
        return httpGet("https://wttr.in/" + city);
    }
}

tool_choice 控制策略:auto(模型自选)、none(禁用)、指定工具名(强制)。并行 tool calls 可一次返回多个调用,提升效率。

【3】如何解决工具一直循环调用

循环是 Agent 最常见生产事故 :模型调工具 → 结果不满意 → 又调 → 永不收敛。本项目 ToolLoopContext 的治理手段:

  1. 设最大迭代步数maxSteps(如 8),超过强制结束并兜底回答。
  2. 同参数重复检测:同一工具 + 同一入参连续出现 N 次即停(说明模型在空转)。
  3. 工具结果超长截断:避免把几万字符塞回 prompt 又触发下一轮。
  4. 工具抛错时把错误回灌而非崩溃 :返回 ToolExecutionResultMessage("调用失败:xxx"),让模型自我纠正。
  5. 强制最终文本回答 :当 finish_reason=stop(不再带 tool_calls)才接受为终态。
  6. 输出守卫:终态必须是自然语言或结构化答案,禁止以 tool_calls 收尾。

【4】面试题与回答

Q1:模型是怎么"调用"我的工具的?

A:它不调用。它返回 tool_calls(工具名+JSON 参数),由我的代码执行后把结果回灌,再请求模型。整个过程是"请求-执行-回灌"的循环。

Q2:工具该写详细还是简略的描述?

A:必须详细且写明触发条件。描述就是模型决策的唯一依据,模糊描述会导致误用或漏用。

Q3:Agent 一直调工具停不下来怎么办?

A:限制最大迭代步数、检测重复调用、截断超长结果、工具报错回灌而非崩溃、强制终态为自然语言。

Q4:tool_choice 有哪些值,怎么用?

A:auto(模型自选)、none(本次禁用工具)、具体工具名(强制调用该工具)。强制指定常用于"必须走某个固定流程"的场景。


【四】Prompt 工程 + 模板化开发

【1】核心思想与结构化写法

Prompt 工程的本质:把"模糊的人类意图"翻译成"模型高概率做对的事"。结构化写法四要素:

  • 人设层:你是谁、什么口吻(system);
  • 规则层:硬约束(只输出 JSON、禁止解释);
  • 业务层:具体任务与上下文;
  • 样例层:Few-shot 示例。

顺序很重要:稳定不变的内容(人设、规则、RAG 文档、样例)放最前,易变内容(时间戳、本轮问题)放最后------这直接决定【缓存章节】的命中率。

【2】PromptTemplate 模板引擎

LangChain4J 内置 dev.langchain4j.model.input.PromptTemplate

java 复制代码
PromptTemplate tpl = PromptTemplate.from("把{{text}}翻译成{{target}}");
String prompt = tpl.apply(Map.of("text","你好","target","英语")).text();
  • from(text) 只做一次编译(解析 {``{var}}),结果可反复 apply
  • apply(Map) / apply(Variable...) / apply(String, Object) 多种重载;
  • 支持默认值、缺失变量报错;
  • 编译结果应缓存 :本项目用 ConcurrentHashMap.computeIfAbsent(templateText, PromptTemplate::from),同一模板不重复编译。

【3】变量占位符与动态 Prompt

{``{var}} 占位 + apply 注入,避免字符串拼接导致注入与语义分叉。动态 Prompt = 同一骨架 + 不同变量(用户问题、检索到的知识、时间)。本项目 PromptRendererapply 前做契约校验与掩码(防敏感信息泄露)。

【4】Few-shot 提升准确率

给 3~6 组"输入→期望输出"样例,模型照葫芦画瓢,显著提准。本项目 FewShotSelector 不是写死样例,而是按 embedding 相似度从样例库动态挑最相关的几条------问题不同,给的样例也不同,比静态 few-shot 更稳。

【5】Prompt 分层设计

本项目落地为两张表:

  • sys_prompt_seg:段落库(人设/规则/业务/样例四类 content,含 {``{占位符}});
  • sys_prompt_asm + sys_prompt_asm_item:装配定义(查哪些段、按什么顺序、条件过滤)。

渲染时 PromptRenderer 只做三件事:查表 → 条件过滤 → 逐段渲染 。好处:改文案在页面改 sys_prompt_seg.content,下一句对话即生效,无需发版;L12/L13 两个智能体引用同一份 SQL 规则行,改一次两边同时生效,根治"双份模板人工同步漂移"。

【6】Prompt 缓存、复用、统一管理

(1)应用层统一管理

段落库 + 装配(上节)即"统一管理":模板不再散落在各 Handler 的字符串里,而是集中、可版本、可审计。

(2)🔥 厂商前缀缓存(降本核心,呼应你"提高缓存命中率"之问)

机制:厂商对相同前缀 的输入跳过 prefill,只按 cached_tokens 折扣计费(OpenAI ~1/10、DeepSeek 0.1 元/百万 vs 1 元/百万)。

提升命中率的工程纪律

  • 稳定前缀放最前:system、RAG 文档、few-shot、工具 schema 全部前置;
  • 易变内容放最后 :时间戳、request_id、本轮问题留末尾;
  • 模板常量化:同类任务用同一 system 模板,不每次重新拼接;
  • 归一化 prompt:去首尾空白、JSON key 排序,避免"语义同字节不同"导致哈希 miss;
  • 多轮保留稳定段:只追加新轮次,别每轮重 prefill 整段历史。

本项目 sys_prompt_seg 的人设/规则段天然稳定且前置,正是缓存友好的写法。

【7】面试题与回答

Q1:Prompt 分层有什么好处?

A:人设/规则/业务/样例分离后,可独立维护、集中管理、页面热改即生效,且多智能体复用同一段规则不会漂移。

Q2:Few-shot 是越多越好吗?

A:不是。3~6 条高质量、覆盖边界的样例最佳;过多占用上下文且可能引入噪声。进阶做法是用 embedding 动态选最相关样例。

Q3:怎么提高厂商前缀缓存命中率?

A:稳定内容(system/RAG/样例/工具 schema)放最前,易变内容(时间/本轮问题)放最后;模板常量化;prompt 归一化;多轮只追加不重拼。命中后输入 token 走折扣计费。

Q4:PromptTemplate 为什么要缓存编译结果?

A:from() 要解析占位符,纯内存微秒级但高频重复;缓存避免反复编译,且防止缓存旧正文导致的模板漂移。

【8】问题整理

(1)提示词分成了几层?每层的内容与作用

演进:早期概念里是「人设/规则/业务/样例」四块(见【5】),落地时进一步把"事实"与"方法论"拆开,形成了 5 个语义层 ,由一个枚举 PromptLayer 承载(代码见 com.example.langchain4j.chat.prompt.PromptLayer,取值 PERSONA/RULES/CONTEXT/GUIDANCE/BUSINESS,与 sys_prompt_seg.layer 列一一对应)。「层(layer)」是段落的固有语义身份,而「拼到哪条消息(slot)」是另一个正交维度------层决定语义小标题,槽决定落到 system / user / append(详见【9】),两者解耦。

五层从上到下(一次对话提示词的叠放顺序):

层(枚举 code) 中文 这段写什么内容 作用 典型 slot 真实段落示例
PERSONA 人设层 你是谁、专业背景、语气立场(如"你是严谨的私有知识库问答助手") 设定角色,让模型站在正确立场回答 SYSTEM common.assistant.personarag.personadata_analysis_plus.persona
RULES 规则层 输出格式、长度、边界、禁止项(只输出 JSON / 严禁写操作 / 图表类型口径) 硬性约束,压住模型自由发挥 SYSTEM code_gen.rulessql.hard_rulesdata_analysis.chart_type
CONTEXT 上下文层 本轮可用的事实:RAG 片段、表结构、方言、时间锚点、场景手册、对话记忆 给模型"已知的事实",是动态内容最集中的一层 SYSTEM rag.contextanalysis.contextplus.time_anchorplus.tools_guide
GUIDANCE 引导层 把事实转成动作的方法论:工具怎么选、图表怎么挑、多步怎么拆 教模型"怎么做",降低乱用工具/格式跑偏 SYSTEM(或 APPEND) rag.tools_fusionplus.decision_rulesplus.multistepdata_analysis.enhanced_guide
BUSINESS 业务层 本次任务的具体输入(需求 / 待分类文本 / 参数) 承载用户这一次的真实诉求 USER code_gen.businessclassify.business

为什么必须分这五层(设计动机):

  • CONTEXT 与 GUIDANCE 必须拆开:上下文是"已知事实"(塞得多模型会照抄),引导是"行动方法论"(决定怎么用事实)。混在一起,模型会把"工具决策表"误当成"事实"去复述。
  • BUSINESS 单独成层且 slot=USER:用户本轮输入天然是 user 消息,从历史消息里能单独合并,避免每轮重复发送同一句话。
  • 层与槽正交code_gen.rules 是 RULES 层 + SYSTEM 槽;code_gen.business 是 BUSINESS 层 + USER 槽。层决定"语义身份/小标题",槽决定"落到哪条消息"------同一段既能进 system 也能进 user,页面改一列即可。

落盘时的小标题PromptLayer.marker() 会在 system 消息里给每层加一个小标题(【人设层】/【规则层】/【上下文层】/【引导层】/【业务层】),日志与预览都能一眼看出"这段从哪个语义层来"。

system_rule 的关系 :只有 PERSONA 层可能被请求级 system 参数整体替换(当装配 system_rule=REPLACE);LOCK 的装配(L11/L12/L13)人设连同后续的"工具/SQL 决策表"一起锁死,前端一个输入框换不掉------因为 L11~L13 的人设与"工具/SQL 决策表"互相咬合,被换掉整条链路就自相矛盾(见【9】阶段二的 ②-2)。

(2)一份现在的真实提示词案例(含动态填充点)

builtin.data_analysis_plus(L13 数据分析 Plus) 这套装配为例------它动态变量最多,最能说明"哪些是写死、哪些是运行时注入"。

装配清单(来自 sys_prompt_asm_item,顺序即落盘顺序):

seq 段落 code slot 条件 正文是否含动态占位符
1 data_analysis_plus.persona PERSONA SYSTEM --- 否(静态人设)
2 plus.time_anchor CONTEXT SYSTEM --- 是(10 个日期变量)
3 plus.tools_guide CONTEXT SYSTEM --- 是(toolsGuide)
4 plus.decision_rules GUIDANCE SYSTEM --- 否(静态决策表)
5 plus.multistep GUIDANCE SYSTEM --- 否(静态编排规则)
6 sql.hard_rules RULES SYSTEM --- 否(L12/L13 共用,全局段)
7 data_analysis.chart_type RULES SYSTEM ---
8 analysis.context CONTEXT SYSTEM --- 是(5 个上下文变量)

最终发给模型的 system 消息(节选,动态部分用 ‹...› 标出):

text 复制代码
【人设层】
你是一名企业数据分析助手(增强版):既能查询企业数据库,也能调用外部工具与企业RAG知识库,
回答需要跨工具协作的复合问题。

【上下文层】
# 当前时间锚点(务必以此为准,不要用你记忆中的日期)
今天是 ‹2026-09-09›(‹星期三›)。
- 今年 = ‹2026› 年,区间 ‹2026-01-01› ~ ‹2026-12-31›
- 去年 = ‹2025› 年,区间 ‹2025-01-01› ~ ‹2025-12-31›
- 本季度 = ‹2026› 年第 ‹3› 季度
- 近 90 天 = ‹2026-06-11› ~ ‹2026-09-09›
凡涉及「今年 / 去年 / 本季度 / 近 N 天 / 上个月」等相对时间,一律按上表换算成具体日期区间......

# 你可用的工具
‹exec_sql / datetime / calculator / exchange / metals / search_knowledge / geo / notify ...›

【引导层】
# 调用决策规则(严格遵守,逐条对照后再决定)
1. 需要真实业务数据(统计 / 求和 / 排名 / 趋势 / 占比 / 明细)→ exec_sql。
......(静态决策表,逐条对照)......

【规则层】
# 硬性规则(SQL 写法)
1. 仅允许 SELECT 查询,或以 WITH 开头的 CTE;严禁 INSERT/UPDATE/DELETE......
......(静态 SQL 规则)......

【上下文层】
# 本数据源典型分析场景(场景手册,按当前连接的库动态注入)
‹本库存在「店铺组织树」模型......(来自 scenario.allen-ai 段)›

# 数据库方言说明
‹MySQL 8.0:日期函数用 DATE_FORMAT......(来自数据源方言配置)›

# 数据库表结构(括号里的注释是字段/表的业务含义......)
‹store(store_id 店铺ID, store_name 店铺名, store_type 1全国总店/2省总店/3市分店......)(来自 getSchema 实时拉取)›

# 相关业务知识(来自知识库......)
‹(无)›

# 对话上下文
‹(最近 6 轮摘要,来自 ChatMemory)›

哪些是动态填充的(运行时注入,每次不同):

动态变量 来源 何时算 说明
today / weekday / year / yearStart / yearEnd / lastYear / lastYearStart / lastYearEnd / quarter / d90Start 时间工具 每轮重算 模型自身时间认知不可信,必须注入锚点(plus.time_anchor 段)
toolsGuide sys_tools.agent_guide 按本轮启用工具 只描述真正启用的工具清单(plus.tools_guide 段)
scenarioNotes scenario.<库名> 段 / 或 (无) 按当前数据源 场景手册,无则填"(无)"
dialectNotes 数据源方言配置 按数据源 不同库 SQL 方言不同
schemaText getSchema(dsCode) 实时拉表结构 调模型前 入 token 大头,已做按需注入封顶(见【七】优化)
ragHints RAG 检索 调模型前 业务词→表/字段映射,无则"(无)"
memoryContext ChatMemory 最近 N 轮摘要 调模型前 多轮上下文

哪些是写死的(随装配/段落库下发,管理页可改): 人设层、SQL 硬性规则、图表类型规则、工具决策表、多步编排规则、增强分析引导段------都是 sys_prompt_seg.content 里的静态正文,改文案在页面改段即可,下一句对话生效。

对比案例(条件填充,非变量填充)builtin.rag(L11)的 rag.contextcondition_var=context只有当本轮真的检索到知识时才拼rag.tools_fusioncondition_var=tools只有本轮勾选了外部工具才拼------这是"整段按条件出现/消失",与上述"段内变量替换"是两种不同维度的动态。

(3)提示词生成的代码逻辑(数据驱动 + 四阶段装配)

① 数据底座:四张表 + 一份不可变快照

PromptCatalog(单例 Bean)在启动与变更时把四张表读成一份不可变快照(60s TTL,事件驱动失效):

  • sys_prompt_seg(段落库):一段可复用正文,带 layer 语义层 + scope 复用范围 + content(可含 {``{占位符}});
  • sys_prompt_asm(装配头):一套装配的方案,含 system_rule / example_top_k / example_budget_chars / variables 契约;
  • sys_prompt_asm_item(装配明细):一行 = "某段排第几(seq_no)、拼到哪个槽(slot)、受哪个条件变量支配(condition_var)";
  • sys_prompt_example(样例库):Few-shot 样例,按 tags / keywords 命中。

为什么四张表一次读、整体换 :装配是"段+顺序+槽位+条件"的组合,分开缓存会出现"新明细引用了还没进缓存的段"这种自造故障;整表一次换就没有这个问题。快照装载期就把"按 seq 排序的段序列""每段占位符集合""每个装配的样例池"都算好,运行期 assemble 只做查表与渲染,不在对话线程里排序、解析 JSON 或猜类型

启动即校验(warmUpPromptAssemblies.WARM_UP 里的 6 个内置装配(assistant / code_gen / classify / rag / data_analysis / data_analysis_plus)及其引用的每段都必须存在且启用,否则应用启动失败并点名缺失 code------少一段提示词的对话会让模型对着 {``{schemaText}} 输出花括号,这种问题留在启动期发现,别留到用户提问时。

② 装配主流程:PromptAssembler.assemble() 四阶段

入口有三层重载(assemble(code, vars) / assemble(code, vars, sampleText) / assemble(code, vars, sampleText, systemOverride)),最终都收敛到 4 参方法。四个阶段:

java 复制代码
// 阶段一:查表取装配定义(段顺序已在快照里排好,本方法只按序消费)
PromptCatalog.Asm asm = catalog.assembly(asmCode);
Map<String, Object> v = vars == null ? Map.of() : vars;
// 是否允许「请求级 system 替换人设层」:装配未 LOCK 且确实传了非空 system
boolean overridePersona = !asm.lockSystem() && systemOverride != null && !systemOverride.isBlank();
java 复制代码
// 阶段二:按段顺序遍历装配条目,做条件过滤与逐段渲染
for (PromptCatalog.Item item : asm.items()) {
    // ②-1 条件段:条件变量无值 → 整段跳过(避免渲染出空白占位)
    if (item.conditional() && isBlankValue(v.get(item.conditionVar()))) { skipped.add(...); continue; }
    PromptCatalog.Seg seg = catalog.segment(item.segCode());
    // ②-2 人设层整层替换:允许替换 + 本次是 PERSONA 层 → 用请求级 system 顶掉(只插一次)
    if (seg.layer() == PromptLayer.PERSONA && overridePersona) { ...; continue; }
    // ②-3 正常渲染:模板 + 占位符用变量表渲染
    String rendered = renderer.render(seg.content(), seg.placeholders(), v, where).trim();
    if (rendered.isEmpty()) { skipped.add("正文为空"); continue; }   // ②-3b 空正文也跳过
    // ②-4 按槽位落桶
    switch (item.slot()) {
        case SYSTEM -> appendBlock(systemBlocks, seg.layer(), rendered);
        case APPEND -> appendBlocks.add(rendered);
        case USER   -> userBlocks.add(rendered);
    }
}
java 复制代码
// 阶段三:Few-shot 选样(按 sampleText 信号从样例库挑最相关若干条,拼到 system 末尾)
FewShotSelector.Selection selection = selector.select(asm, catalog.examples(asm.code()), sampleText);

// 阶段四:拼接落盘 ------ system 由「分层块 + 选样块 + append 块」三段顺序拼成
StringBuilder system = new StringBuilder();
for (Map.Entry<PromptLayer, StringBuilder> e : systemBlocks.entrySet())
    system.append(e.getKey().marker()).append('\n').append(e.getValue()).append("\n\n");
if (!selection.block().isBlank()) system.append(selection.block().stripTrailing()).append("\n\n");
for (String append : appendBlocks) system.append(append).append("\n\n");
// 产出 AssembledPrompt(system / 可选 userMessage / used / skipped / examples / explain)

③ 渲染与防坑:PromptRenderer

  • 契约校验先于替换 :LC4j 的 apply 缺值只说"variable xxx missing"、不告诉你哪套装配哪一段;本类先按 where(装配+段 code)补齐全信息,缺值直接抛 IllegalArgumentException------提示词少一个变量宁可请求都不发。
  • 值内占位符掩码 :LC4j 默认按 entry 顺序 String.replace,若某变量值里恰好含 {``{...}}(用户把带占位符的模板当变量传、或 RAG 片段里带花括号),后续会被二次替换污染;本类替换前把值内占位符换成一次性哨兵、渲染完再还原,并打 WARN。
  • 归一化{``{ var }}{``{var}},让"正文占位符"与"variables 清单"比对不因空白产生假差异。

④ Few-shot 按需选样:FewShotSelector

全量注入的问题不是"样例多",而是"不相关的样例抢注意力"。本类按信号打分:

  • tags 命中信号(用户文本/上一轮分类结果)→ 主分 10/个(标签是人工标注的类别,最可信);
  • keywords 命中 → 加分 1/个(应对中文标签打不中的场景);
  • weight desc, sort_no asc 只做稳定排序,保证同一输入永远同一份提示词(否则"预览看到的"和"运行期发出的"对不上);
  • 一条都没命中 → 按权重兜底取前 topK(如 code_gen 那三条无标签样例本是"示范格式"用的);
  • topK 与字符预算都在装配头声明,超预算的样例按分从低到高丢弃。

⑤ 缓存友好的顺序纪律(呼应【6】):人设/规则/样例这些稳定段排最前(SYSTEM 槽、按层加小标题),时间锚点/表结构/本轮问题这些易变内容靠后到 CONTEXT 层与 USER 消息------稳定前缀在前,厂商前缀缓存命中率才高。

关键设计点小结

  • 运行期与管理页预览共用同一份 assemble 逻辑AssembledPrompt.explain 既是日志也是预览回显,保证"预览看到的"和"模型收到的"必然一致;
  • 段落库是单一事实来源 :改文案在页面改 sys_prompt_seg.content,下一句对话即生效,无需发版;
  • 多智能体复用同一段 (如 sql.hard_rulesanalysis.context 被 L12/L13 共用),改一处两边同时生效,根治"双份模板人工同步漂移"。

【五】RAG 知识库

【1】RAG 完整原理

RAG(检索增强生成)= 让模型先查资料再回答,解决"模型不知道私域知识/会瞎编"的问题。链路:

文档加载 → 切片 → 向量化(Embedding) → 入库(向量库) → 检索(用户问题向量化后相似度查找) → 拼接 Prompt(上下文增强) → 模型生成

本项目 SysRagServiceImpl 管理知识库生命周期,RagVectorStoreFactory 负责入库与检索。

【2】Embedding 嵌入模型与向量

  • Embedding 模型:把文本映射成固定维度浮点向量(如 1024 维),语义相近的文本向量"距离"近。
  • 什么是向量/向量查询:文本 → 高维空间中的点;查询时把问题也向量化,找"最近邻"的点。
  • 本项目EmbeddingClientFactory + DashScopeEmbeddingClient 等;resolveEmbeddingModel 优先用指定 id 且必须是 EMBEDDING 类且 supported,否则回退第一个支持的向量模型(防拿 VL 模型去向量化的坑)。

【3】文档切片策略

(1)固定切片 vs 递归字符切片
策略 做法 缺点
固定字符/按行 每 N 字符一段 易把一句话/代码块斩断
RecursiveCharacterTextSplitter \n\n\n→字符 优先级递归切 官方推荐,尽量在语义边界断
(2)overlap(重叠)------ 最常被漏掉

相邻 chunk 保留一定重叠(如 100 字符),避免一句话被斩断后检索丢失上下文 。本项目 ChunkRecord 记录切片元数据即为此。

(3)结构感知切片

Markdown 按标题、代码按块切,比纯字符切更准。选型原则:文档有清晰结构 → 结构感知;纯文本 → 递归字符 + overlap。

【4】向量数据库基础

  • 为什么用向量库:关系型数据库做"等于/范围"查询,做不了"语义最近邻";向量库用 ANN(近似最近邻)索引(HNSW 等)高效检索。
  • 内存向量库入门InMemoryEmbeddingStore 零依赖、适合 demo;生产换持久化。
  • 本项目多后端RagStorageType 支持多种存储,RagVectorStoreFactory 按类型建库------是选型落地的现成案例。

【5】向量库选型

特点 适用
InMemory 零依赖 测试/demo
PgVector 复用 PostgreSQL 已有 PG、要事务
Redis 内存快 高并发缓存型
Milvus / Qdrant 专业向量库 海量、高维
Chroma 轻量 原型
Elasticsearch 全文+向量 混合检索

【6】相似度检索、TopN 召回

  • TopN(maxResults):返回最相似的 N 个 chunk(一般 3~8);
  • minScore:低于阈值的不要,过滤噪声;
  • 相似度如何提高 :更好的 Embedding 模型、合理的切片与 overlap、查询改写(Query Rewriting)、重排(Rerank)------本项目可接 dashscope/cohere reranker 对初召回再排序;
  • 混合检索 :向量(语义)+ BM25(关键词)融合召回率更高,本项目 Bm25Index 即实现 BM25 分支,与向量结果融合。

【7】检索结果拼接与上下文增强

EmbeddingStoreContentRetrieverRetrievedSegmentsDefaultRetrievalAugmentor 把片段拼进 Prompt 模板的"知识"位。本项目 RagService.retrieve 返回 RetrieveResult,上游把文本塞进 [知识库] 段再生成。关键:只塞相关片段,控制总长度防超限

【8】RAG 优化与幻觉抑制

  • 阈值过滤(minScore)挡低质召回;
  • 引用溯源(回答标注出处 chunk);
  • 答案约束"仅基于给定资料,不知就说不知";
  • Rerank 提质;
  • 评估(见下)。

【9】什么情况适合 RAG

适合:私域知识、时效性强、需溯源、答案要带出处。不适合:模型已具备的常识、纯推理、实时计算(那种该用 Tool)。

【10】PDF 等格式解析切分

FileSystemDocumentLoader/ClassPathDocumentLoader/UrlDocumentLoader 载入;PDF 用 Apache PDFBox / Apache Tika 抽取文本,再走切片管线。本项目文档入库链路即此路径。

【11】如何判断改用 RAG

当"模型答不出/答错私域事实"且"知识会变"时上 RAG;若知识稳定且要改模型行为,考虑微调;若单次超长上下文够装,先用长上下文。

【12】多个 RAG 库的管理与路由

  • 可以多个:不同业务域建不同库;
  • 管理 :本项目 RagKbCatalog 登记多个知识库(code/名称/绑定 embedding 模型/存储类型);
  • 路由 :请求带 embeddingModelIdkbCoderesolveEmbeddingModel 以库绑定优先,决定查哪个库;等价于 RoutingRetriever 思路。

【13】面试题与回答

Q1:RAG 为什么能减少幻觉?

A:把回答锚定在检索到的真实资料上,并约束"只基于资料作答、不知就说不知",降低模型凭空编造。

Q2:切片为什么要 overlap?

A:避免一句话被斩断导致检索时上下文丢失,相邻 chunk 重叠部分可补偿边界信息。

Q3:向量库和普通关系库区别?

A:关系库做精确/范围查询,向量库做语义最近邻(ANN)检索,解决"意思相近"的查找。

Q4:TopN 的 N 怎么定?

A:一般 3~8。太小漏信息,太大稀释注意力且超 token。配合 minScore 过滤低质结果,必要时 Rerank 提质。

Q5:混合检索是什么?

A:向量语义检索 + BM25 关键词检索融合,兼顾语义与精确词面匹配,召回率更高。本项目用 Bm25Index 实现。


【六】Agent 智能体

【1】运行原理

Agent = 自主思考 + 自主选工具 + 自主迭代 的闭环。本质是【三】工具循环的升级:模型拿到任务,自己决定调哪些工具、调几次,直到能回答。经典范式 ReAct(Reason + Act):先推理下一步,再行动(调工具),观察结果,再推理......直到 finish_reason=stop

【2】LangChain4J Agent 组件

声明式:@AiService + @Tool 方法即一个 Agent,框架自动跑工具循环。

命令式(本项目):ToolLoopContext 持有步数计数与上下文,ToolAwareLlmHandler 驱动"生成→若有 tool_calls 则执行回灌→再生成"的循环,直到终态或达上限。

【3】多模型动态切换

本项目 LlmModelRegistry / LlmClientFactorycode 解析 ChatLanguageModel,支持 deepseek / 通义(qwen-dashscope) / ollama 等。智能体配置里指定 modelId,运行时据此取模型实例------配置驱动而非硬编码,改模型只改库不发包。

【4】全局超时、重试、异常降级

  • 超时:给每次模型调用设超时(如 30s),超时抛异常走降级;
  • 重试:对 429/5xx 指数退避重试,对 4xx 不重试;
  • 降级 :重试耗尽返回友好兜底(本项目 GlobalExceptionHandler 统一返回"接口报错"占位,不暴露原始异常)。

【5】Token 统计、计费、日志埋点

本项目 sys_llm_call 逐次记录 prompt_tokens/completion_tokens/total_tokens/reasoning_tokens/elapsed_ms/first_token_ms/tool_count/statussys_llm 存每模型独立单价(¥/1M token),成本 = 入/1M×inPrice + 出/1M×outPrice。ChatModelListener 是埋点接入点。驾驶舱(CockpitVO)把这堆数据聚合成 KPI + 趋势 + 环比(MoM),并可扩展"缓存命中率""节省 token"。

【6】生产级避坑

  • 上下文溢出:窗口记忆 + 主动裁剪 + RAG 替代全文;
  • 流式乱码 :SSE 分块要按字符边界切(尤其中文多字节),避免把一个 UTF-8 字拆成两包;本项目 StreamCallback 处理增量;
  • 工具死循环:见【三】第 3 节 maxSteps + 重复检测;
  • 成本失控:token 埋点 + 单价 + 告警。

【7】面试题与回答

Q1:Agent 和单次工具调用有什么区别?

A:单次工具调用是"模型调一次工具就回答";Agent 是自主多步循环------自己决定调哪些、调几次,直到能回答,具备自主性与迭代性。

Q2:声明式 Agent 和手写工具循环怎么选?

A:简单场景用 @AiService+@Tool 快;需要防跑飞、精细 SSE 事件、可观测(tool_count/first_token_ms)时用本项目这种手写循环。

Q3:Agent 怎么防止跑飞/死循环?

A:最大步数上限、重复调用检测、结果截断、报错回灌、强制终态为自然语言、输出守卫。

Q4:怎么给 LLM 应用做成本可观测?

A:逐次记 token 与耗时,按模型单价算成本,聚合到驾驶舱看趋势与环比,并监控缓存命中率。


【七】当前项目(allen-ai)做了哪些优化设计

以下均来自 langchain4j-springboot-demo + langchain4j-system 真实代码,可直接作为"项目实战"面试题素材。

【1】多模型动态注册与热切换

LlmClientFactory / LlmModelRegistry / LlmModelCategory / LlmProvider:按 code 解析出 ChatLanguageModel,覆盖 deepseek、qwen(dashscope)、ollama;解析失败时回退手动注册实例。配置驱动,改模型不发包

【2】成本与计费体系

  • sys_llm 每模型独立单价,计价单位 ¥/1M token(每百万 tokens);
  • sys_llm_call 全字段留存(四类 token + 耗时 + tool_count + status);
  • 后端按 model_id 分组聚合 token 再乘单价,避免多模型混合失真;
  • 驾驶舱 CockpitVO 聚合 KPI/趋势/环比(MoM) ;并预留 cached_tokens/缓存命中率字段。
  • 修复过的关键 bug :原成本"前一窗口"聚合缺上界导致成本环比恒为 0,已加 endExclusive 对齐口径。

【3】多轮记忆持久化

ChatMemoryProvider + sys_chat_message 双写:memory_row=1 的行即 ChatMemory 持久化来源;role='boundary' 行作为"清空记忆"边界。MessageWindowChatMemory max=20 控长度。重启不丢、清空不毁审计。

【4】手写 Agent 工具循环

ToolLoopContext + ToolAwareLlmHandler:为防跑飞 / 可观测 / SSE 事件粒度 而自研(非纯 @AiService)。循环编排 + maxSteps 防死循环;埋点 tool_countfirst_token_ms

【5】分层 Prompt 段落库 + 装配

sys_prompt_seg(人设/规则/业务/样例四层,含 {``{占位符}})+ sys_prompt_asm(装配定义)。PromptRenderer 查表→过滤→渲染。页面改 content 即生效,多智能体复用同一规则行防漂移。

【6】多 RAG 库路由 + 混合检索

RagKbCatalog 登记多知识库;RagVectorStoreFactory + RagStorageType 支持多后端;Bm25Index 实现 BM25 与向量融合的混合检索;resolveEmbeddingModel 以库绑定优先解析向量模型。

【7】参数模板化

ParamTemplateCatalog / ParamSpec / ParamPreset:按模型固化 temperature/top_p/max_tokens 等,避免每次手写、保证同模型行为一致、便于回归。

【8】全局异常与友好提示

GlobalExceptionHandler 统一拦截,前端恒定显示"接口报错"占位(-),不暴露原始异常堆栈;区分可重试/不可重试错误做退避。

【9】面试题与回答

Q1:你们项目怎么做到"换模型不发包"?

A:模型配置存库(sys_llm),运行时 LlmClientFactory 按 code 解析实例,智能体配置只存 modelId,改库即生效。

Q2:成本怎么从"每千"改成"每百万"且不改历史金额?

A:单价 ×1000 同时除数 ×1000,二者抵消,存量数据金额不变;运行库用 SQL 按旧值精准匹配守卫做幂等迁移。

Q3:多轮记忆为什么用 boundary 行而不是物理删除?

A:清空是用户视角动作,boundary 标记"到此为止",既满足清空体验又保留审计轨迹,且 ChatMemory 加载只取到 boundary。

Q4:为什么手写 Agent 循环而不是用 @AiService?

A:需要精细控制防跑飞(maxSteps)、SSE 逐字事件粒度、可观测埋点(tool_count/first_token_ms),声明式在这些点上的可控性不足。

Q5:RAG 检索为什么做混合(BM25+向量)?

A:向量抓语义、BM25 抓精确词面,融合后召回率更高,尤其用户用专业术语/编号提问时 BM25 补偿语义召回的盲区。


【八】一次完整对话全流程拆解(像一次 Debug)

把前面七个板块串起来:一个请求从前端发出,到最终答案回到前端,中间经过哪些组件、哪些方法、在哪一步准备参数、在哪一步调模型、又在哪一步调工具/检索。整个过程可分成 五个阶段 ,下面按"时间顺序 + 代码定位"逐段拆解,并在每段点出难点/亮点

【1】组件架构图(一张图看清分层与旁路)

渲染错误: Mermaid 渲染失败: Parse error on line 10: ...atHandler
stream()固定骨架] BH[B -----------------------^ Expecting 'SQE', 'DOUBLECIRCLEEND', 'PE', '-)', 'STADIUMEND', 'SUBROUTINEEND', 'PIPE', 'CYLINDEREND', 'DIAMOND_STOP', 'TAGEND', 'TRAPEND', 'INVTRAPEND', 'UNICODE_TEXT', 'TEXT', 'TAGSTART', got 'PS'

【2】时序图(一次请求的生命周期)

LlmCallRecorder 厂商API(Ollama/DeepSeek) AbstractLlmClient.doStream 具体Handler(Basic/Rag/Agent) AbstractChatHandler ChatService ChatController 前端 LlmCallRecorder 厂商API(Ollama/DeepSeek) AbstractLlmClient.doStream 具体Handler(Basic/Rag/Agent) AbstractChatHandler ChatService ChatController 前端 #mermaid-svg-j8lKAcVCEmoo4mbB{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-j8lKAcVCEmoo4mbB .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-j8lKAcVCEmoo4mbB .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-j8lKAcVCEmoo4mbB .error-icon{fill:#552222;}#mermaid-svg-j8lKAcVCEmoo4mbB .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-j8lKAcVCEmoo4mbB .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-j8lKAcVCEmoo4mbB .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-j8lKAcVCEmoo4mbB .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-j8lKAcVCEmoo4mbB .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-j8lKAcVCEmoo4mbB .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-j8lKAcVCEmoo4mbB .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-j8lKAcVCEmoo4mbB .marker{fill:#333333;stroke:#333333;}#mermaid-svg-j8lKAcVCEmoo4mbB .marker.cross{stroke:#333333;}#mermaid-svg-j8lKAcVCEmoo4mbB svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-j8lKAcVCEmoo4mbB p{margin:0;}#mermaid-svg-j8lKAcVCEmoo4mbB .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-j8lKAcVCEmoo4mbB text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-j8lKAcVCEmoo4mbB .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-j8lKAcVCEmoo4mbB .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-j8lKAcVCEmoo4mbB .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-j8lKAcVCEmoo4mbB .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-j8lKAcVCEmoo4mbB #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-j8lKAcVCEmoo4mbB .sequenceNumber{fill:white;}#mermaid-svg-j8lKAcVCEmoo4mbB #sequencenumber{fill:#333;}#mermaid-svg-j8lKAcVCEmoo4mbB #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-j8lKAcVCEmoo4mbB .messageText{fill:#333;stroke:none;}#mermaid-svg-j8lKAcVCEmoo4mbB .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-j8lKAcVCEmoo4mbB .labelText,#mermaid-svg-j8lKAcVCEmoo4mbB .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-j8lKAcVCEmoo4mbB .loopText,#mermaid-svg-j8lKAcVCEmoo4mbB .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-j8lKAcVCEmoo4mbB .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-j8lKAcVCEmoo4mbB .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-j8lKAcVCEmoo4mbB .noteText,#mermaid-svg-j8lKAcVCEmoo4mbB .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-j8lKAcVCEmoo4mbB .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-j8lKAcVCEmoo4mbB .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-j8lKAcVCEmoo4mbB .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-j8lKAcVCEmoo4mbB .actorPopupMenu{position:absolute;}#mermaid-svg-j8lKAcVCEmoo4mbB .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-j8lKAcVCEmoo4mbB .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-j8lKAcVCEmoo4mbB .actor-man circle,#mermaid-svg-j8lKAcVCEmoo4mbB line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-j8lKAcVCEmoo4mbB :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} ★调模型前的参数准备:buildMessages() AgentToolRunner 执行工具→回灌messages→再请求模型(循环) alt本轮有 tool_calls (仅 Agent/Rag+工具)最终回答 POST /api/chat/stream (ChatRequestDTO)new SseEmitter+AgentRunContext(traceId)包 RecordingStreamCallbackstream(req, callback, ctx, cancelled)applyAgentConfig(智能体套用,可能改写mode)bindRunScope/bindHistoryContextTraceMdc.bind(trace)handlerFactory.getHandler(mode).stream()beforeChat() 钩子记忆读取 / PromptAssembler装配人设RagHandler→ragService.retrieve() 预检索(拼context)DataAnalysis→getSchema()拼表结构buildRequest()→ChatRequest(modelName+params)streamToModel→client.stream(model,req,...,options)begin() 留痕句柄(读MDC三元组)buildPayload() 厂商差异(含options)HTTP POST 异步流(stream:true)逐行 NDJSON/SSEparseLine→onPartialResponse(token)handler.onToken(token)→SSE "token"事件逐字推送流末 onToolCalls(toolCalls)onToolCall(name,args)→SSE "tool"事件🔧 工具调用标识finishSuccess(usage) 留痕onComplete(text,tokens)afterChat() 写回记忆SSE "done"事件→emitter.complete()

【3】阶段一:请求接入与运行配置合成(ChatController → ChatService)

核心代码做什么:

  • ChatController.chatStream() 收到 ChatRequestDTO,做三件基础设施工作:① 建 SseEmitter(流式超时取 agent.sse-timeout-ms,是整条 agentic 工具循环的总预算 而非单轮);② new AgentRunContext() 生成 traceId 作为全链路追踪号;③ 用 RecordingStreamCallback 装饰 SseStreamCallback,把"一轮被中断"的留痕收口包在这里。
  • 请求被 CompletableFuture.runAsync(..., analysisExecutor) 提交到线程池,控制器立即返回 emitter,不阻塞 HTTP 线程
  • ChatService.stream() 进入后:TraceMdc.bind(trace) 把 traceId 绑进 MDC(后续所有日志自动带 trace)→ applyAgentConfig(req, ctx)resolveStreamDefaultbindRunScopebindHistoryContexthandlerFactory.getHandler(mode).stream()

本阶段最关键的一步 ------ applyAgentConfig(智能体组合配置套用):

如果前端带了 agentCode,这里会就地改写请求 :以 sys_agent.base_mode 选运行内核(可能把前端选的 mode 改成另一个 handler)、把智能体的模型/向量模型/数据源/知识库/工具/参数模板/提示词装配,填进前端没显式指定的字段 。优先级是 请求显式值 > 智能体配置 > 运行配置 。这意味着同一个对话入口,mode 真正路由到哪个 handler,可能在进工厂之前就被改写了。

难点 / 亮点:

  • 全链路 traceId :MDC 在提交线程绑定,但 doStream 的回调会跑在 JDK HttpClient 的选择器线程上,所以 AbstractLlmClient 在提交前 TraceMdc.snapshot()、回调里 TraceMdc.restore() 双保险恢复------否则日志会丢 trace。
  • 前端只读开关 :思考过程、流式输出都已收归数据库(sys_config/sys_user_config),由后端合成,前端只展示只读徽章(取值见 GET /api/chat/config)。
  • 取消/中断双通道 :非流式靠 requestId 置位 cancelled;流式靠 SSE 连接断开(push() 写失败即同步 cancelled.set(true))。

【4】阶段二:调模型接口之前的参数准备(handler.buildMessages)------ 这是你最关心的"准备过程"

AbstractChatHandler.stream() 模板方法 按顺序做:beforeChat(req)buildMessages(req, ctx)buildRequest(...)streamToModel(...)。其中 buildMessages 是抽象方法(策略核心) ,每个模式的"个性"全在这里,也就是调模型接口前的全部参数准备

模式 buildMessages 里准备了什么
Basic 最朴素:只包一条 UserMessage.from(message)无 system、无记忆
Memory / Agent ChatMemoryProvider 拿历史 → PromptAssembler.assemble() 装配系统人设 (人设/规则/业务/样例分层)→ SystemMessage + 记忆
Rag ★在调模型之前ragService.retrieve() 做向量化+混合检索 TopN,把命中 chunk 拼进 system 的 context 变量;零命中直接短路返回"暂无相关知识",根本不调模型
DataAnalysis / Plus getSchema(dsCode) 拉整库表结构,按相关性排序+按需注入拼进 system(详见【七】优化)

buildMessages 返回的 List<ChatMessage> 就是发给模型的 messages 数组(system / user / assistant / tool 四种 role)。紧接着 buildRequest() 把它包成 ChatRequest(含 modelNameChatRequestParameters;params 模式走 buildRequestWithCustomParams按厂商参数对象下推 ,Ollama 用 OllamaChatRequestParameters、OpenAI 系用 DefaultChatRequestParameters,根治"统一对象导致专属参数被静默丢弃")。

三个"调用前"的准备要点(务必记住):

  1. 模型解析 resolveModel(req)req.modelIdLlmModelRegistry.resolve() 反查具体模型(厂商、base_url、remote_model_name);id 缺失/无效回退默认模型。留痕写库必须用同一口径,否则"实际调的模型"与"历史记的模型"对不上。
  2. 思考开关 thinkingOptions(req, ctx) :按 智能体三态 > 用户覆盖 > 全局 解析成 LlmCallOptionsenableThinking),随 client.stream(..., options) 传入。(当前 OllamaClient.buildPayload 注释明确"刻意不消费 options"------Ollama 没有 OpenAI 那种显式关闭参数,Qwen3 在 Ollama 上始终思考,这是已诊断但未落盘的待修项,会导致入/出 token 虚高、响应慢。)
  3. 工具 schema 的准备时机不在 buildMessages,而在进入工具循环时AgentHandler/RagHandler(+工具) 覆盖 streamToModelAgentToolRunner.runWithTools(),由 buildToolsSchema()ToolCatalog(读 sys_tools)构造下发给模型的 function schema------只对"启用且可执行"的工具生成。

结论(回答你的疑问):

  • RAG 什么时候访问?调模型之前buildMessages 阶段就完成(检索增强 = 上下文预注入)。调模型之后 RAG 不再触发(RagChatHandler 是单轮检索)。
  • 工具 schema 什么时候准备?streamToModelrunWithTools 入口,与消息装配分离。

【5】阶段三:进入"调模型" ------ streamToModel 与客户端模板方法

AbstractChatHandler.streamToModel() 默认实现:resolveModel(req)llmClientFactory.getClient(model.getProvider())client.stream(model, request, cancelled, handler, thinkingOptions)各模式覆盖它

  • AgentHandler 覆盖为 agentToolRunner.runWithTools(...)(带工具循环);
  • RagHandler 无工具 → client.stream;有工具 → runWithTools
  • 其余模式用默认(无工具基础流式)。

AbstractLlmClient(抽象基类,模板方法)的 doStream()真正发请求的唯一咽喉

  1. llmCallRecorder.begin(...)提交线程上建留痕句柄(此刻 MDC 还有归因三元组 session/agent/mode);
  2. buildPayload(model, request, messages, tools, options) ------ 厂商差异点 :Ollama 拼 /api/chat 的 NDJSON(含 messages/stream:true/tools/options);DeepSeek 拼 /chat/completions 的 SSE;
  3. HttpClient.sendAsync(...) 异步 POST(共享 HttpClient 带连接池,避免每轮新建);
  4. 读流 while(readLine):先用 cancelled 检测,为真立即 is.close()模型真正停止生成 );否则 parseLine() 填充 ParseState(partialDelta / reasoningDelta / toolCalls / usage);
  5. 流末:若 state.toolCalls 非空,统一触发一次 handler.onToolCalls()
  6. call.finishSuccess(usage) 留痕 → handler.onComplete(full, tokens)

难点 / 亮点:

  • 取消即真停cancelled 在流读取循环里每片检测,置位即 close() 底层连接,Ollama/DeepSeek 侧真的停止推理,且刻意不触发 onComplete(避免写记忆把半截答案当完成)。
  • 留痕是唯一咽喉 :基础流式、工具循环每一轮、甚至非对话链路(库表语义改写)都过 doStream,一处埋点覆盖全部,且天然拿到 usage 与取消信号。
  • usage 兼容parseUsage 同时认 OpenAI 的 completion_tokens 与 Ollama 的 eval_count

【6】阶段四:调模型之后发生了什么 ------ 流式回调与"何时调工具 / 何时访问 RAG"

这是你最困惑的"调用接口后的流程从哪开始"。明确回答:

调模型后,doStream 异步收流,每来一个 token 片段就触发 handler.onPartialResponse(token) (经 LlmStreamHandler → 经 ChatController.SseStreamCallback.onToken → SSE 推 "token" 事件 → 前端逐字渲染)。思考链 reasoningDelta 走独立的 "reasoning" 事件,不污染答案文本。

工具调用什么时候发生?------ 不是"边想边调",而是"流结束统一检测"。

doStream整条流读完后 才检查 state.toolCalls

  • 若本轮有 tool_calls → 回调 onToolCall(name, args)(前端显示 🔧)→ 交给 AgentToolRunner 执行 → 结果 appendToolResult 回灌进 messages再发起下一轮模型请求。这就是 agentic 循环。
  • 若本轮无 tool_calls → 这就是最终答案,onComplete 收尾。

RAG 什么时候发生?------ 回到阶段二 :在调模型之前就已检索并注入上下文;调模型之后 RAG 不再触发。

AgentToolRunner 工具循环(agentic loop)详述:

复制代码
for round in 0..maxRounds:
    if round == last: 撤下 tools 表 + 注入"最后一轮直接作答"指令(强制收敛)
    rr = callModel()  // client.streamWithTools,1s 分片阻塞等待 + 整轮预算兜底
    if rr 有 tool_calls:
        去重指纹检测(防原地打转)
        if 全只读且开启并行: 同轮并行提交 Future,按序取结果
        else: 串行执行 registry.execute()
        onToolResult(卡片通道) + appendToolResult(截断后回灌)
        continue  // 进入下一轮
    else: 最终答案 onComplete; break

四重防护 :① 取消响应(1s 分片+总预算超时);② 轮数上限 maxRounds(最后一轮强制收敛);③ 重复调用检测 maxRepeatedToolCalls(同"工具+参数"超限即停);④ 单工具超时(线程池+Future)。两项提效:只读工具同轮并行、中间轮可重试(429/5xx 且零输出时退避重试)。

难点 / 亮点:

  • 重试安全性 :只在"本轮尚未流出任何 token"时才重试,否则重复输出。靠 RoundResult.streamedAnything 标志判定。
  • 并行与顺序的一致性 :只读工具并行提交,但结果严格按模型给出的顺序回灌,保证消息序列可复现。
  • 工具结果截断 :回灌前 truncate()tool-result-max-chars,完整数据走"结果卡片"通道,避免上下文逐轮膨胀超窗。

【7】阶段五:收尾 ------ afterChat、留痕、SSE 关闭

  • onCompleteAbstractChatHandlerafterChat(req, answer, ctx) 钩子:把回答写回 ChatMemory空回答不写回 ,防污染后续上下文)。AgentHandler 还做了 sanitizeHistory 剔除空白助手轮。
  • LlmCallRecorder.finishSuccess 异步(historyExecutor 单线程池)写 sys_llm_callprompt_tokens/completion_tokens/reasoning_tokens/total_tokens + elapsed_ms + first_token_ms + status一次 agentic 对话打 5 次模型就记 5 行,共用同一个 trace_id------"越聊越慢"是哪一轮 prefill 慢,按 trace 展开即见。
  • SSE:onComplete"done" 事件(text + tokens)→ emitter.complete()。非流式路径则在 ChatService.recordNonStreamTurn 记一轮留痕。
  • 前端中断/超时:emitter.onTimeout 先推 error 事件再关闭,否则前端只会看到"正在生成..."无征兆停住。

【8】难点与亮点总览

难点(踩坑点):

  1. 跨线程 trace 传递:提交线程 → HttpClient 选择器线程 → 回调线程,MDC 需 snapshot/restore 双保险。
  2. 中断的因果确定性 :SSE 写失败必须同步 置位 cancelled,否则"用户点停止"会被误记为"模型失败",拉偏失败率统计。
  3. 工具循环防跑飞:轮数上限、重复检测、并行/顺序回灌一致性、重试安全性,缺一个就可能在长链路里失控。
  4. 记忆污染防护:空白助手轮剔除 + 空回答不写回,从输入端切断"空输出死亡螺旋"。
  5. 入 token 膨胀:表结构/检索/工具 schema 全量注入(已在【七】做按需注入/封顶优化)。
  6. Ollama 思考开关未转发(当前实现):Qwen3 在 Ollama 上始终思考,入/出 token 虚高、响应慢------已知待修项。

亮点(设计值得学):

  1. 模板方法 + 工厂 + 策略三模式组合AbstractChatHandler 固定"一次对话"骨架,ChatHandlerFactory 按枚举路由,LlmClient 策略解耦厂商------新增模式/厂商都只加实现、不动调用方(开闭原则)。
  2. 全链路可观测 :traceId 贯穿,agent_run / chat_stream 结构化 metric 日志行,按 trace 即可复盘整条链路耗时、轮数、工具成功率、token 成本。
  3. 留痕唯一咽喉doStream 一处埋点覆盖所有模型调用路径。
  4. 取消即真停:close 连接让模型侧真正停止,省算力也避免半截答案落库。
  5. 配置驱动 + 前端只读:开关收归数据库,改配置即生效,前端无脑展示。

【9】面试题与回答

Q1:RAG 是在模型生成过程中调用,还是生成之前?

A:之前。RagHandler.buildMessages() 在调模型前就 ragService.retrieve() 检索并把上下文拼进 system;零命中直接短路不调模型。

Q2:工具调用是流式边生成边执行吗?

A:不是。流结束doStream 才统一检测 tool_calls 并回调,本地执行工具、结果回灌,再发起下一轮模型请求(agentic loop)。

Q3:一次 agentic 对话为什么 sys_llm_call 会有多行?

A:工具循环每轮都过 doStream 咽喉,各自 begin/finishSuccess 记一行,共享同一个 trace_id

Q4:取消请求后,模型侧真的停了吗?

A:停了。cancelled 置位时 doStream 立即 close() 底层 HTTP 流,Ollama/DeepSeek 真正停止生成;前端断开(SSE 写失败)同理同步置位。

Q5:为什么 Ollama 那条 prompt_tokens 常是 NULL?

A:LlmCallRecorder.fillTokens 只认 prompt_tokens/prompt_count,不认 Ollama 实际返回的 prompt_eval_count------已知待修项,补一行识别即可。

Q6:思考过程为什么不下发到 Ollama?

A:当前 OllamaClient.buildPayload 注释明确"刻意不消费 options"------Ollama 没有 OpenAI 兼容层那种显式关闭参数,thinking 由模型自身决定;要根治需在 buildPayload 下发顶层 think 字段(已定位,待落盘)。


【九】当前项目用到了哪些设计模式?

设计模式不是炫技,而是"把变化隔离开、把稳定固定住"的落地手段。本项目(langchain4j-springboot-demo + langchain4j-system)在多处刻意 用了经典模式,下面逐一对照真实代码(com.example.langchain4j 包)说明:每个模式都给「是什么 → 本项目落点 → 解决什么痛点」三段,并附最小代码片段。所有类名/方法名均可直接去源码 grep 验证。

【1】模式总览(一张表看全)

# 设计模式 本项目落点(真实类/接口) 解决的核心痛点
1 模板方法 Template Method AbstractChatHandlerAbstractLlmClient 固定"一次对话/一次请求"的骨架,把易变的消息装配、厂商差异留给子类
2 工厂 Factory + 策略 Strategy(组合) ChatHandlerFactoryLlmClientFactoryDataSourceClientFactory 调用方只认工厂+枚举,新增模式/厂商/库类型零改动调用方
3 策略 Strategy LlmClient 接口族(OllamaClient/OpenAiCompatClient)、AbstractChatHandler 子类族 同一算法族(怎么调模型/怎么聊)多实现,运行时按 key 切换
4 注册表/目录 Registry LlmModelRegistryToolRegistryRagKbCatalogParamTemplateCatalog 配置入 DB 后,运行时"按 code 反查实例 + 缓存 + 事件失效"
5 不可变快照 + Cache-Aside PromptCatalog.SnapLlmModelRegistrysnapshot 四表/模型清单一次读、整体换,运行期只读不可变对象,不在对话线程里排序/解析
6 装饰器 Decorator RecordingStreamCallbackStreamCallbackLlmStreamHandler 包回调 透明加"留痕"能力,不改被包装对象的 SSE 行为
7 观察者/事件监听 Observer @TransactionalEventListener(监听 PromptChangedEvent/LlmChangedEvent);LangChain4J ChatModelListener 配置变更 → 快照自愈;请求/响应埋点解耦
8 建造者 Builder ChatRequest.builder()LlmModel.builder()*.from(...) 参数多、可选多,链式构造可读且防构造半截对象
9 上下文对象 Context Object AgentRunContext 贯穿 buildMessages→afterChat→工具循环 替代 ThreadLocal 跨线程传参,根治流式回调里 ThreadLocal 拿不到/泄漏
10 享元/单例共享资源 Flyweight AbstractLlmClient.httpClient() 共享连接池 避免每轮 new HttpClient,连接复用、限流可控
11 代理 Proxy(框架提供) LangChain4J @AiService 生成的代理 声明式对话,对比本项目"命令式手写循环"的取舍(见【二】【六】)

【2】模板方法 Template Method ------ 项目里最重的模式

是什么 :父类定义final的算法骨架,把"会变化的点"抽成抽象方法钩子方法(可重写可空),子类只填变化点。

本项目落点 ①:AbstractChatHandler(对话骨架)

java 复制代码
// 骨架:final,子类改不了顺序 ------ 一次对话永远走 beforeChat → buildMessages → buildRequest → streamToModel
public final void stream(ChatRequestDTO req, StreamCallback cb, BooleanSupplier cancelled, AgentRunContext ctx) {
    beforeChat(req);                                       // 钩子(默认空)
    ChatRequest request = buildRequest(buildMessages(req, ctx), req); // buildMessages=抽象方法(策略核心)
    streamToModel(req, request, cancelled, new LlmStreamHandler(){...onComplete→afterChat...}, ctx);
}

protected abstract List<ChatMessage> buildMessages(ChatRequestDTO req, AgentRunContext ctx); // 抽象:每个模式的"个性"
protected void beforeChat(ChatRequestDTO req) {}   // 钩子
protected void afterChat(ChatRequestDTO req, String answer, AgentRunContext ctx) {} // 钩子(记忆模式重写写回记忆)
  • 抽象方法 buildMessages():Basic 只包一条 UserMessage;Memory/Rag/Agent 读记忆+装配人设;DataAnalysis 拉表结构------"调模型前准备什么"全部集中在这一个方法里(呼应【八】阶段二)。
  • 钩子 beforeChat/afterChat/buildRequest:默认空或默认实现,MemoryChatHandler 重写 afterChat 把回答写回记忆,ParamsChatHandler 重写 buildRequest 加采样参数。

本项目落点 ②:AbstractLlmClient(模型调用骨架)

java 复制代码
// final 的"发请求→逐行解析→回调"骨架,厂商差异只留在 buildPayload() 这一个点上
public final void stream(LlmModel model, ChatRequest req, BooleanSupplier cancelled,
                        LlmStreamHandler handler, LlmCallOptions options) {
    Map<String,Object> body = buildPayload(model, request, toNativeMessages(request), null, options); // ← 唯一变化点
    LlmCallRecorder.Call call = llmCallRecorder.begin(...);       // 留痕咽喉(一次覆盖所有调用路径)
    client.sendAsync(httpReq, ofInputStream()).thenAccept(resp -> {
        TraceMdc.restore(mdcSnapshot);                            // 跨线程恢复 trace
        while ((line = br.readLine()) != null) {                  // 逐行解析 NDJSON/SSE
            if (cancelled.getAsBoolean()) { is.close(); return; } // 取消即真停
            parseLine(line, state);                               // 填充 token/reasoning/toolCalls/usage
        }
        if (!state.toolCalls.isEmpty()) handler.onToolCalls(state.toolCalls); // 流末统一检测工具
        call.finishSuccess(usage); handler.onComplete(full, tokens);
    });
}
  • OllamaClient/OpenAiCompatClient 只重写 buildPayload()toNativeMessages():Ollama 拼 /api/chat 的 NDJSON、DeepSeek 拼 /chat/completions 的 SSE------其余的解析/留痕/取消/跨线程 trace 逻辑一处写、全部复用

解决什么痛点 :新增一个对话模式(如未来加"语音模式")只需 extends AbstractChatHandler 实现 buildMessages+supportedMode,不动 stream 骨架;新增一个厂商(如 Gemini)只需 extends AbstractLlmClient 重写 buildPayload开闭原则(对扩展开放、对修改封闭)的典型落地。


【3】工厂 + 策略组合 ------ 项目里出现三次的"同一套路"

是什么 :工厂负责"按 key 造/取策略对象",策略是那个被造出来的可替换算法。两者组合 = 调用方只认抽象接口和枚举,完全不写 if/else 判断厂商

本项目三个工厂,结构一模一样 (Spring 注入所有实现,启动时建 EnumMap):

java 复制代码
// ChatHandlerFactory:mode(枚举) → Handler(策略)
@Component
public class ChatHandlerFactory {
    private final Map<ChatMode, AbstractChatHandler> handlers;
    public ChatHandlerFactory(List<AbstractChatHandler> all) {
        this.handlers = new EnumMap<>(ChatMode.class);
        for (AbstractChatHandler h : all) handlers.put(h.supportedMode(), h); // 每个 Handler 自己声明负责哪种 mode
    }
    public AbstractChatHandler getHandler(ChatMode mode) { return handlers.get(mode); } // 调用方无 if/else
}

// LlmClientFactory:provider(枚举) → LlmClient(策略) ------ 同理
// DataSourceClientFactory:dbType(枚举) → DataSourceClient(策略) ------ 同理
  • 调用点(AbstractChatHandler.streamToModel):llmClientFactory.getClient(model.getProvider()).stream(...)------业务侧完全不感知 Ollama/DeepSeek 差异。
  • 路由点(ChatService):handlerFactory.getHandler(mode).stream(...)------前端传 mode 枚举即可,无需知道具体 Handler 类名。

解决什么痛点 :三层(对话模式 / 模型厂商 / 数据库类型)各自独立扩展。加一个 Mysql8DataSourceClient 只需实现 DataSourceClient@ComponentDataSourceClientFactory 构造器自动收进 EnumMap,调用方零改动。这正是开闭原则 + 消除条件分支的组合拳。


【4】注册表 / 目录 Registry ------ 配置入 DB 后的"运行期索引"

是什么 :把"可变配置/元数据"从硬编码变成"DB 行 + 运行期单例索引",对外暴露 getById/getDefault/resolve 等查询能力,内部带缓存与失效机制。

本项目四个注册表

注册表 数据来源 关键能力
LlmModelRegistry sys_llm resolve(id)(请求指定优先、缺失回退默认)、getDefault()getDefaultVector()
ToolRegistry sys_tools 按 code 反查可执行工具、agent_guide 文案
RagKbCatalog sys_rag_kb 多知识库登记、按 embeddingModelId/kbCode 路由
ParamTemplateCatalog sys_param_template 按模型固化 temperature/top_p/max_tokens

为什么需要它(痛点) :模型清单、工具、知识库、参数模板都进了 DB(见【七】"换模型不发包"),但运行期每秒多次反查。若每次都 SELECT,DB 压力大且"解析规则不一致"会出 bug------比如"实际调的模型"和"历史页记的模型"若各写一遍回退逻辑就会对不上。所以所有回落规则只住注册表里一处,全项目共用同一口径。


【5】不可变快照 + Cache-Aside ------ 让"热路径只读、不怕并发"

是什么 :把"可能变化的源数据"一次性读成不可变对象(record + unmodifiableMap ,用 volatile + 时间戳做 TTL 缓存;失效靠"事件监听"主动清,而非被动等过期。

本项目落点:PromptCatalog(四表快照)与 LlmModelRegistry(模型快照)

java 复制代码
// PromptCatalog:四表读成一份不可变 Snap,整体替换;运行期 assemble() 只读不写
private volatile Snap snap = Snap.EMPTY;            // Snap 是 private record,构造即不可变
private volatile long snapTs = 0L;
private Snap current() {                            // Cache-Aside:命中期内直接返回
    if (System.currentTimeMillis() - snapTs < CACHE_TTL_MS) return snap;
    return reload(System.currentTimeMillis());
}
private synchronized Snap reload(long now) { /* 四表读齐 → Collections.unmodifiableMap 包成新 Snap → 整体替换 */ }

// 失效:管理页改段 → 事务提交后发 PromptChangedEvent → 监听清快照 → 下一句对话即读新数据
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
public void onPromptChanged(PromptChangedEvent event) { this.snapTs = 0L; this.snap = Snap.EMPTY; }

解决什么痛点 :① 装配是"段+顺序+槽位+条件"的组合,四张表必须一次读齐 ,分开缓存会出现"新明细引用了还没进缓存的段"的自造故障------整体换就杜绝了;② 运行期 assemble 只查表+渲染,不在对话线程里排序/解析 JSON/猜类型 (排序、占位符抽取都在装载期算好);③ 60s TTL + 事件失效,事件丢了也能自愈;④ 回源失败保留旧快照并告警,对话不中断。


【6】装饰器 Decorator ------ 透明加"留痕"不污染主链路

是什么 :用同一个接口包住原对象,对每个方法"先做事再转发",被包装对象完全不知道被加了能力

本项目落点:RecordingStreamCallbackStreamCallback

java 复制代码
public class RecordingStreamCallback implements StreamCallback {  // ← 同一接口
    private final StreamCallback delegate;                        // ← 被包装的原始回调(SseStreamCallback)
    private final AtomicBoolean recorded = new AtomicBoolean(false);

    @Override public void onComplete(String fullText, Long tokens) {
        if (recorded.compareAndSet(false, true))                 // CAS 保证"一轮只记一次"
            history.recordTurn(req, ctx, TurnOutcome.success(fullText, tokens, toolCalls.get(), elapsed()));
        delegate.onComplete(fullText, tokens);                   // 原样转发,不改变任何 SSE 行为
    }
    @Override public void onError(String msg) {
        if (recorded.compareAndSet(false, true))
            history.recordTurn(req, ctx, cancelled.getAsBoolean()
                ? TurnOutcome.cancelled(...) : TurnOutcome.error(...));  // 区分"中断 vs 模型失败"
        delegate.onError(msg);
    }
}

为什么必须包在回调上(而不是包在 ChatService.stream() 里)stream()立即返回 的------它只把请求发给模型并注册回调,真正的回答在之后的 HTTP 线程上逐 token 回来。所以在 stream() 返回那一刻"既没有答案也没有耗时",唯一能观察到"一轮真正结束"的地方就是回调的 onComplete/onError。装饰器把留痕收口在这里,且不漏异常给对话链路。

解决什么痛点 :留痕与"推送给前端的 SSE"彻底解耦------加埋点不用改 ChatController,且 CAS 幂等解决了"前端点停止被误记为模型失败"的因果错乱(呼应【八】难点 #2)。


【7】观察者 / 事件监听 Observer ------ 配置变更自愈 + 埋点解耦

是什么:发布者只管发事件,订阅者各自响应,双方互不知晓。

本项目落点 ①:Spring 事务事件监听(配置自愈)

java 复制代码
// 管理页改了模型/提示词 → 事务提交后才发事件 → 注册表/目录清快照 → 下一句对话读到新数据
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
public void onLlmChanged(LlmChangedEvent event) { this.snapshotTs = 0L; ... }   // LlmModelRegistry
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
public void onPromptChanged(PromptChangedEvent event) { this.snapTs = 0L; ... }  // PromptCatalog

本项目落点 ②:LangChain4J ChatModelListener(请求/响应埋点)

ChatModelListener 是框架提供的观察者钩子,本项目用它做为 token/成本/耗时的埋点接入点(见【六】第5节),与"真正调模型"的业务代码解耦------加监控不影响对话主流程

解决什么痛点:配置页改完立刻生效,且"发事件"与"清缓存"解耦,新增一个关心"模型变了"的订阅者(如清 RAG 缓存)也不会改发布者代码。


【8】建造者、上下文对象、享元 ------ 三个"小而关键"的模式

① 建造者 Builder(可读性 + 防半截对象)

java 复制代码
ChatRequest req = ChatRequest.builder()
    .messages(messages)
    .parameters(ChatRequestParameters.builder().modelName(name).temperature(0.3).build())
    .build();

LlmModel.builder()(注册表 toModel 映射行→视图)、*.from(...)SystemMessage.fromUserMessage.from)同理。痛点:对话请求字段极多且多可选,Builder 让构造可读、且不会漏必填项。

② 上下文对象 Context Object(替代跨线程 ThreadLocal)

AgentRunContext(含 traceId/agentThinkingOverride/工具循环中间态)被显式作为参数贯穿 buildMessages→streamToModel→afterChat→AgentToolRunner痛点 :旧实现用 ThreadLocal 在 buildMessagesafterChat 间传"RAG 是否零命中",但流式路径下 afterChat 跑在模型回调线程上、与写入线程不是同一个------ThreadLocal 既拿不到值也会泄漏。收敛到上下文对象后跨线程自然正确(呼应【八】)。

③ 享元/单例共享资源 FlyweightAbstractLlmClient.httpClient()

java 复制代码
private volatile HttpClient sharedClient;   // 本客户端单例内惰性创建并缓存,带连接池
HttpClient httpClient() { if (sharedClient==null) synchronized(this){ if(sharedClient==null) sharedClient = HttpClient.newBuilder().poolSize(...).build(); } return sharedClient; }

痛点 :每轮 new HttpClient 会重建连接池、慢且不可控限流;共享一个带池的实例,所有对话复用连接,且取消时 is.close() 直接断底层连接让模型侧真停(呼应【八】亮点 #4)。


【9】模式如何协同(一次请求的"组合拳")

单一模式价值有限,本项目真正厉害的是把它们串成一条链。以一次 Agent 对话为例:

复制代码
前端请求 ──▶ ChatController
   │  new SseEmitter + new AgentRunContext(traceId)        【Context Object:贯穿全程】
   │  new RecordingStreamCallback(原始SSE回调)             【Decorator:包一层留痕】
   ▼
ChatService.applyAgentConfig(...)
   │  handlerFactory.getHandler(mode)                     【Factory + Strategy:枚举路由到 Handler】
   ▼
AbstractChatHandler.stream()  ← final 骨架               【Template Method】
   │  buildMessages() 抽象方法 → PromptAssembler.assemble()
   │     └─ PromptCatalog.assembly() 读不可变 Snap        【Registry + Immutable Snapshot】
   ▼
AbstractChatHandler.streamToModel()
   │  llmClientFactory.getClient(provider)                【Factory + Strategy:厂商路由到 LlmClient】
   ▼
AbstractLlmClient.doStream()  ← final 骨架               【Template Method】
   │  buildPayload() 由 OllamaClient/OpenAiCompatClient 重写  【Strategy:厂商差异点】
   │  httpClient() 共享连接池                              【Flyweight】
   │  llmCallRecorder.begin() 留痕(ChatModelListener 思路)  【Observer 埋点】
   ▼
模型流式返回 → RecordingStreamCallback.onToken → SSE "token" 事件 → 前端
   │  流末检测 tool_calls → AgentToolRunner 循环(每轮再走一遍 doStream 咽喉)
管理页改模型 → LlmChangedEvent → LlmModelRegistry 清快照   【Observer 自愈】

一句话总结工厂+策略负责"选谁",模板方法负责"怎么跑",注册表+快照负责"跑之前数据从哪来且稳定",装饰器负责"顺手埋点不污染主链路",观察者负责"改了配置自动生效",上下文对象负责"跨线程不丢失状态"------这套组合让"换模型不发包 / 加模式不动骨架 / 改提示词热生效 / 全链路可观测"都成了水到渠成的结果,而不是东补西补的临时方案。


【10】面试题与回答

Q1:你们项目用模板方法模式解决了什么问题?

A:把"一次对话"和"一次模型调用"的不可逆骨架固定在抽象基类(AbstractChatHandler.stream/AbstractLlmClient.doStreamfinal),把易变点(buildMessagesbuildPayload)抽成抽象/可重写方法。新增对话模式或厂商只加子类、不动骨架,符合开闭原则。

Q2:工厂模式和策略模式在你项目里是怎么配合的?

A:工厂(ChatHandlerFactory/LlmClientFactory/DataSourceClientFactory)启动时把"所有实现"按枚举收进 EnumMap,调用方只 getHandler(mode)/getClient(provider) 拿策略对象,全程无 if/else 判断厂商/模式。工厂负责"造/取",策略(LlmClientAbstractChatHandler 子类)负责"执行",两者组合实现"调用方零改动扩展"。

Q3:为什么提示词/模型要用"不可变快照"而不是每次查 DB?

A:三点------① 装配是"段+顺序+槽位+条件"的组合,四表必须一次读齐,分开缓存会自造"引用未缓存段"故障,整体换杜绝;② 运行期 assemble 只读不可变对象,不在对话线程里排序/解析 JSON;③ 60s TTL + 事件失效,回源失败还能用旧快照兜底,对话不中断。

Q4:RecordingStreamCallback 为什么是装饰器而不是在 service 里直接写留痕?

A:因为 stream() 立即返回、回答在之后的 HTTP 线程逐 token 回来,唯一能观察"一轮真正结束"的是回调的 onComplete/onError。装饰器包住原始 SSE 回调,对每个方法"先留痕再转发",不改变任何 SSE 行为,且用 CAS 保证一轮只记一次、区分"用户中断 vs 模型失败"。

Q5:你们怎么做到改了模型/提示词配置立刻生效?

A:配置入 DB(sys_llm/sys_prompt_*),运行期由 LlmModelRegistry/PromptCatalog 这类注册表+快照持有;管理页写操作后事务提交发 XxxChangedEvent@TransactionalEventListener(AFTER_COMMIT) 监听并清快照,下一句对话即读新数据。事件丢了还有 60s TTL 自愈。

Q6:为什么用 AgentRunContext 而不用 ThreadLocal 传参?

A:流式对话下 afterChat 跑在模型回调线程、与 buildMessages 的写入线程不是同一个,ThreadLocal 既拿不到值又会在线程池里泄漏。把 traceId/思考开关/工具中间态收敛到一个显式传递的上下文对象,跨线程自然正确,也更好测试。


文档(含【八】)完毕。所有"实战"均可在 langchain4j-springboot-democom.example.langchain4j)与 langchain4j-systemcom.example.langchain4j.system)源码中对照查证;配套已有 LangChain(3)框架LangChain(6)PromptLangChain(7)RAGLangChain(8)Agent 等专题文档可交叉阅读。

相关推荐
lisin-lee-cooper1 小时前
简单聊聊 Spring 框架源码
java·后端·spring
复方金银花颗粒2 小时前
reactor和proactor的好处和坏处。为什么要用reactor而不用 proactor?
后端·信号处理
边境悍匪2 小时前
蜗牛学苑 Java 智能体学习 Day38|Spring AI Alibaba2 思维导图复盘
java·学习·spring
林石工作室2 小时前
体育直播APP从0到1搭建指南:技术选型、核心模块与部署实战
spring boot·流媒体·app搭建·体育直播app·星逐赛事
IT_陈寒2 小时前
JavaScript的这个隐式转换特性差点让我加班到凌晨
前端·人工智能·后端
摘星星的屋顶2 小时前
2026年8月31日~2026年9月13日周报
人工智能·学习
用户8356290780513 小时前
使用 Python 在 Excel 中添加和编辑形状
后端·python
用户204937554953 小时前
端侧语音部署踩坑:模型能跑不等于终端真的能用
后端·算法