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-chat、qwen-plus、gpt-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 ------ 选哪个模型
决定能力、价格、上下文长度与是否支持工具/视觉。生产环境建议按场景路由不同模型 :简单分类用小模型省钱,复杂推理用大模型。本项目用 LlmClientFactory 按 code 解析出 ChatLanguageModel,LlmModelCategory 区分 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 数 | 长文本、防超限更精准 |
本项目 ChatMemoryConfig 用 MessageWindowChatMemory.builder().maxMessages(20),由 app.chat.memory-max-messages 控制:只留最近 20 条,更早丢弃 → 上下文有上限 → 避免 Token 超限。
(2)多轮隔离与持久化
ChatMemoryProvider 以 sessionId 为 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() 返回 ChatMessageType,text() 取文本(部分子类才有)。常用工厂:
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=stop→AiMessage.text()有值,是最终答案; - 当
finish_reason=tool_calls→AiMessage.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 数截断); - 本项目
ChatMemoryConfig用MessageWindowChatMemory.builder().maxMessages(20)。
ChatMemoryProvider(包 dev.langchain4j.memory) :按 sessionId(memoryId)工厂式 产出 ChatMemory,并负责缓存/隔离/持久化。
- 接口只有一句:
ChatMemory get(Object memoryId); - 多会话隔离:内部通常用
Map<memoryId, ChatMemory>(本项目用ConcurrentHashMap)保证不同会话互不串; - 持久化:本项目
ChatMemoryProvider从sys_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):增量 tokenonReasoning(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) |
emitAnswer → emitter.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"/...))
│
▼
浏览器逐字渲染
两个极易踩的坑(本项目已用代码根治):
-
"客户端断开"必须同步置位 cancelled :
SseStreamCallback.push()在emitter.send抛IOException/IllegalStateException时,当场cancelled.set(true)。若等emitter.onCompletion异步置位,线程调度不确定,用户每点一次"停止"历史里就多一条"模型失败",污染失败率统计。这是本项目因果确定性的关键设计。 -
中断 vs 模型失败必须分清(幂等 CAS) :
RecordingStreamCallback(StreamCallback的装饰器)包在SseStreamCallback外层,用AtomicBoolean recorded的 CAS 保证一轮只留痕一次。逻辑:onComplete→ success;onError且cancelled=true→ 记 cancelled(用户中断)而非 error;- 前端断开 / 超时,模型侧再无回调 → 由
emitter.onCompletion调recordAborted()补记 cancelled; - 异步任务自身炸了(连模型都没调上)→
ChatController的whenComplete调recordTaskFailed()。
四类收尾对应四种留痕,互不重复,且都不把异常漏给对话链路。
为什么 SseStreamCallback 要在 Controller 层 new,而不是包进 ChatService :chatService.stream() 是立即返回 的------它只把请求发给模型并注册回调,真正的回答在之后的 llm-http 线程上逐 token 回来。所以"一轮真正结束"只能由回调(onComplete / onError)或 SSE 生命周期(onCompletion)观察到,留痕装饰器必须包在 Controller 这一层。
【三】Tool 工具的使用
【1】实现原理
Tool(函数调用)让模型从"只会说"变成"会办事"。核心误区:模型并不会真的调用你的函数。真实链路是:
- 你把所有可用工具的
ToolSpecification(名称 + 描述 + 入参 JSON Schema)随请求发给模型; - 模型只返回 一个
tool_calls(含工具名 + JSON 参数),自身不执行; - 你的代码根据
tool_calls找到对应方法执行,得到结果; - 把结果包成
ToolExecutionResultMessage回灌进 messages,再次请求模型; - 模型基于工具结果生成最终自然语言回答(或继续调下一个工具)。
本项目 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 的治理手段:
- 设最大迭代步数 :
maxSteps(如 8),超过强制结束并兜底回答。 - 同参数重复检测:同一工具 + 同一入参连续出现 N 次即停(说明模型在空转)。
- 工具结果超长截断:避免把几万字符塞回 prompt 又触发下一轮。
- 工具抛错时把错误回灌而非崩溃 :返回
ToolExecutionResultMessage("调用失败:xxx"),让模型自我纠正。 - 强制最终文本回答 :当
finish_reason=stop(不再带 tool_calls)才接受为终态。 - 输出守卫:终态必须是自然语言或结构化答案,禁止以 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 = 同一骨架 + 不同变量(用户问题、检索到的知识、时间)。本项目 PromptRenderer 在 apply 前做契约校验与掩码(防敏感信息泄露)。
【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.persona、rag.persona、data_analysis_plus.persona |
RULES |
规则层 | 输出格式、长度、边界、禁止项(只输出 JSON / 严禁写操作 / 图表类型口径) | 硬性约束,压住模型自由发挥 | SYSTEM | code_gen.rules、sql.hard_rules、data_analysis.chart_type |
CONTEXT |
上下文层 | 本轮可用的事实:RAG 片段、表结构、方言、时间锚点、场景手册、对话记忆 | 给模型"已知的事实",是动态内容最集中的一层 | SYSTEM | rag.context、analysis.context、plus.time_anchor、plus.tools_guide |
GUIDANCE |
引导层 | 把事实转成动作的方法论:工具怎么选、图表怎么挑、多步怎么拆 | 教模型"怎么做",降低乱用工具/格式跑偏 | SYSTEM(或 APPEND) | rag.tools_fusion、plus.decision_rules、plus.multistep、data_analysis.enhanced_guide |
BUSINESS |
业务层 | 本次任务的具体输入(需求 / 待分类文本 / 参数) | 承载用户这一次的真实诉求 | USER | code_gen.business、classify.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.context段condition_var=context,只有当本轮真的检索到知识时才拼 ;rag.tools_fusion段condition_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 或猜类型。
启动即校验(warmUp) :PromptAssemblies.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_rules、analysis.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】检索结果拼接与上下文增强
EmbeddingStoreContentRetriever 出 RetrievedSegments,DefaultRetrievalAugmentor 把片段拼进 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 模型/存储类型); - 路由 :请求带
embeddingModelId或kbCode,resolveEmbeddingModel以库绑定优先,决定查哪个库;等价于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 / LlmClientFactory 按 code 解析 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/status。sys_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_count、first_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)→resolveStreamDefault→bindRunScope→bindHistoryContext→handlerFactory.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(含 modelName 与 ChatRequestParameters;params 模式走 buildRequestWithCustomParams,按厂商参数对象下推 ,Ollama 用 OllamaChatRequestParameters、OpenAI 系用 DefaultChatRequestParameters,根治"统一对象导致专属参数被静默丢弃")。
三个"调用前"的准备要点(务必记住):
- 模型解析
resolveModel(req):req.modelId→LlmModelRegistry.resolve()反查具体模型(厂商、base_url、remote_model_name);id 缺失/无效回退默认模型。留痕写库必须用同一口径,否则"实际调的模型"与"历史记的模型"对不上。 - 思考开关
thinkingOptions(req, ctx):按 智能体三态 > 用户覆盖 > 全局 解析成LlmCallOptions(enableThinking),随client.stream(..., options)传入。(当前OllamaClient.buildPayload注释明确"刻意不消费 options"------Ollama 没有 OpenAI 那种显式关闭参数,Qwen3 在 Ollama 上始终思考,这是已诊断但未落盘的待修项,会导致入/出 token 虚高、响应慢。) - 工具 schema 的准备时机 :不在 buildMessages,而在进入工具循环时 。
AgentHandler/RagHandler(+工具)覆盖streamToModel→AgentToolRunner.runWithTools(),由buildToolsSchema()按ToolCatalog(读sys_tools)构造下发给模型的 function schema------只对"启用且可执行"的工具生成。
结论(回答你的疑问):
- RAG 什么时候访问? 在调模型之前 、
buildMessages阶段就完成(检索增强 = 上下文预注入)。调模型之后 RAG 不再触发(RagChatHandler 是单轮检索)。 - 工具 schema 什么时候准备? 在
streamToModel→runWithTools入口,与消息装配分离。
【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() 是真正发请求的唯一咽喉:
llmCallRecorder.begin(...)在提交线程上建留痕句柄(此刻 MDC 还有归因三元组 session/agent/mode);buildPayload(model, request, messages, tools, options)------ 厂商差异点 :Ollama 拼/api/chat的 NDJSON(含messages/stream:true/tools/options);DeepSeek 拼/chat/completions的 SSE;HttpClient.sendAsync(...)异步 POST(共享 HttpClient 带连接池,避免每轮新建);- 读流
while(readLine):先用cancelled检测,为真立即is.close()(模型真正停止生成 );否则parseLine()填充ParseState(partialDelta / reasoningDelta / toolCalls / usage); - 流末:若
state.toolCalls非空,统一触发一次handler.onToolCalls(); 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 关闭
onComplete→AbstractChatHandler调afterChat(req, answer, ctx)钩子:把回答写回ChatMemory(空回答不写回 ,防污染后续上下文)。AgentHandler还做了sanitizeHistory剔除空白助手轮。LlmCallRecorder.finishSuccess异步(historyExecutor 单线程池)写sys_llm_call:prompt_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】难点与亮点总览
难点(踩坑点):
- 跨线程 trace 传递:提交线程 → HttpClient 选择器线程 → 回调线程,MDC 需 snapshot/restore 双保险。
- 中断的因果确定性 :SSE 写失败必须同步 置位
cancelled,否则"用户点停止"会被误记为"模型失败",拉偏失败率统计。 - 工具循环防跑飞:轮数上限、重复检测、并行/顺序回灌一致性、重试安全性,缺一个就可能在长链路里失控。
- 记忆污染防护:空白助手轮剔除 + 空回答不写回,从输入端切断"空输出死亡螺旋"。
- 入 token 膨胀:表结构/检索/工具 schema 全量注入(已在【七】做按需注入/封顶优化)。
- Ollama 思考开关未转发(当前实现):Qwen3 在 Ollama 上始终思考,入/出 token 虚高、响应慢------已知待修项。
亮点(设计值得学):
- 模板方法 + 工厂 + 策略三模式组合 :
AbstractChatHandler固定"一次对话"骨架,ChatHandlerFactory按枚举路由,LlmClient策略解耦厂商------新增模式/厂商都只加实现、不动调用方(开闭原则)。 - 全链路可观测 :traceId 贯穿,
agent_run/chat_stream结构化 metric 日志行,按 trace 即可复盘整条链路耗时、轮数、工具成功率、token 成本。 - 留痕唯一咽喉 :
doStream一处埋点覆盖所有模型调用路径。 - 取消即真停:close 连接让模型侧真正停止,省算力也避免半截答案落库。
- 配置驱动 + 前端只读:开关收归数据库,改配置即生效,前端无脑展示。
【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 | AbstractChatHandler、AbstractLlmClient |
固定"一次对话/一次请求"的骨架,把易变的消息装配、厂商差异留给子类 |
| 2 | 工厂 Factory + 策略 Strategy(组合) | ChatHandlerFactory、LlmClientFactory、DataSourceClientFactory |
调用方只认工厂+枚举,新增模式/厂商/库类型零改动调用方 |
| 3 | 策略 Strategy | LlmClient 接口族(OllamaClient/OpenAiCompatClient)、AbstractChatHandler 子类族 |
同一算法族(怎么调模型/怎么聊)多实现,运行时按 key 切换 |
| 4 | 注册表/目录 Registry | LlmModelRegistry、ToolRegistry、RagKbCatalog、ParamTemplateCatalog |
配置入 DB 后,运行时"按 code 反查实例 + 缓存 + 事件失效" |
| 5 | 不可变快照 + Cache-Aside | PromptCatalog.Snap、LlmModelRegistry 的 snapshot |
四表/模型清单一次读、整体换,运行期只读不可变对象,不在对话线程里排序/解析 |
| 6 | 装饰器 Decorator | RecordingStreamCallback 包 StreamCallback;LlmStreamHandler 包回调 |
透明加"留痕"能力,不改被包装对象的 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 并 @Component,DataSourceClientFactory 构造器自动收进 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 ------ 透明加"留痕"不污染主链路
是什么 :用同一个接口包住原对象,对每个方法"先做事再转发",被包装对象完全不知道被加了能力。
本项目落点:RecordingStreamCallback 包 StreamCallback
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.from、UserMessage.from)同理。痛点:对话请求字段极多且多可选,Builder 让构造可读、且不会漏必填项。
② 上下文对象 Context Object(替代跨线程 ThreadLocal)
AgentRunContext(含 traceId/agentThinkingOverride/工具循环中间态)被显式作为参数贯穿 buildMessages→streamToModel→afterChat→AgentToolRunner。痛点 :旧实现用 ThreadLocal 在 buildMessages 与 afterChat 间传"RAG 是否零命中",但流式路径下 afterChat 跑在模型回调线程上、与写入线程不是同一个------ThreadLocal 既拿不到值也会泄漏。收敛到上下文对象后跨线程自然正确(呼应【八】)。
③ 享元/单例共享资源 Flyweight (AbstractLlmClient.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.doStream,final),把易变点(buildMessages、buildPayload)抽成抽象/可重写方法。新增对话模式或厂商只加子类、不动骨架,符合开闭原则。
Q2:工厂模式和策略模式在你项目里是怎么配合的?
A:工厂(ChatHandlerFactory/LlmClientFactory/DataSourceClientFactory)启动时把"所有实现"按枚举收进 EnumMap,调用方只 getHandler(mode)/getClient(provider) 拿策略对象,全程无 if/else 判断厂商/模式。工厂负责"造/取",策略(LlmClient、AbstractChatHandler 子类)负责"执行",两者组合实现"调用方零改动扩展"。
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-demo(com.example.langchain4j)与langchain4j-system(com.example.langchain4j.system)源码中对照查证;配套已有LangChain(3)框架、LangChain(6)Prompt、LangChain(7)RAG、LangChain(8)Agent等专题文档可交叉阅读。