用面向对象封装一个AI Agent框架:从Prompt、LLM到业务Agent的拆解
写在前面
在老年健康管理这个项目里,我前前后后建了六个Agent——意图识别的、通用闲聊的、医疗咨询的、记忆压缩的、记忆提取的、建档引导的。如果每个Agent都单独写一套模型调用、消息组装、流式回调的逻辑,那代码里的重复会多到不敢看,而且加一个新Agent的成本太高。所以我在项目初期就花了一些时间把Agent相关的基础能力抽了出来,抽象成 Prompt、LLM 和 Agent 三个核心类,业务Agent只需要继承 Agent 并传入对应的 Prompt 和 LLM 就行。
这篇文章把这个小框架的设计思路和实现逐层拆开,从最底层的 Prompt 和 LLM 讲起,往上讲到 Agent 基类的封装细节,最后过一遍六个业务Agent和六个Prompt子类的具体分工。不会涉及大模型原理,更多是面向对象的工程实践。
一、整体概览:三层抽象 + 业务子类
整个 agent 目录下的结构非常清晰,三个抽象基类放在最外层,两个子包 agent 和 prompt 分别放置业务 Agent 和业务 Prompt,llm 子包放模型的具体实现。
agent/
├── Agent.java # Agent 基类(Prompt + LLM + 消息管理)
├── Prompt.java # Prompt 基类(系统提示词 + 背景 + 格式约束)
├── LLM.java # LLM 基类(模型连接参数 + 懒加载实例)
├── agent/
│ ├── IntentRecognitionAgent.java
│ ├── GeneralAgent.java
│ ├── MedicalConsultationAgent.java
│ ├── MemoryCompressionAgent.java
│ ├── MemoryExtractionAgent.java
│ └── OnboardingAgent.java
├── prompt/
│ ├── IntentRecognitionPrompt.java
│ ├── GeneralPrompt.java
│ ├── MedicalConsultationPrompt.java
│ ├── MemoryCompressionPrompt.java
│ ├── MemoryExtractionPrompt.java
│ └── OnboardingPrompt.java
└── llm/
└── DeepSeekV4FlashLLM.java
这种组织方式的思路很直白:Prompt 管提示词,LLM 管模型连接,Agent 把两者组合起来并提供消息组装和调用方法。业务Agent不写任何重复逻辑,构造器里传两个参数就完成了一个新Agent的定义。下面逐一展开。
二、Prompt:提示词的模板化封装
Prompt 是整个框架中最纯粹的一层,它的职责只有一个——把发送给大模型的系统消息用结构化的方式组织起来。
@Data
@NoArgsConstructor
@Accessors(chain = true)
public class Prompt {
/** 系统提示词 —— 定义 AI 的角色、语气和行为约束 */
private String systemPrompt;
/** 背景知识 —— 补充给 AI 的领域知识或用户画像 */
private String background;
/** 输出格式约束 —— 如 "strict_json",会追加到系统消息末尾 */
private String responseFormat;
/**
* 构建 LangChain4j 的 SystemMessage 对象
* 按 systemPrompt + background + responseFormat 三段拼接
*/
public SystemMessage buildSystemMessage() {
StringBuilder sb = new StringBuilder();
if (StringUtils.hasText(systemPrompt)) {
sb.append(systemPrompt);
}
if (StringUtils.hasText(background)) {
if (!sb.isEmpty()) {
sb.append("\n\n");
}
sb.append(background);
}
if (StringUtils.hasText(responseFormat)) {
if (!sb.isEmpty()) {
sb.append("\n\n");
}
sb.append(responseFormat);
}
return SystemMessage.from(sb.toString());
}
}
三个字段各有各的用途。systemPrompt 是必需品,定义了Agent的角色定位和行为准则,比如医疗咨询Agent的提示词里会强调「不要输出思路链」「不要使用 think 标签」「遇到高风险症状首句即强调就医紧迫性」。background 是一个可选字段,用于在运行时动态注入领域知识或用户画像——这部分内容在写Prompt子类时不写死,而是由上层Service拼好之后通过 setter 注入。responseFormat 用于约束LLM的输出格式,最典型的是意图识别场景,要求模型返回严格的JSON。
三段拼接的逻辑也很直接,用 \n\n 隔开,哪个字段有值就拼哪个。@Accessors(chain = true) 让调用方可以用链式写法配置参数,比如 new Prompt().setSystemPrompt("...").setBackground("..."),这在Service层动态组装上下文的时候会方便一些。
在实际使用中,Prompt 本身几乎不直接实例化。六个 Prompt 子类各配一个静态的模板字符串,在构造器里用 setSystemPrompt 注入。比如医疗咨询提示词的核心片段是这样的:
您是一位面向老年人的智能语音助手,专注于提供健康咨询和医学知识参考。
## 核心原则
1. 优先考虑回复速度和清晰度,不要长篇大论。
2. 直接用简短的口语化段落回答,像和家人聊天一样自然。
3. 请勿输出思路链、内部推理、思考痕迹或使用 <think> 标签。
4. 如果风险较高,请明确说明并建议及时就医。
意图识别和记忆提取这两个需要结构化输出的 Prompt,除了 systemPrompt 之外还设了 responseFormat。意图识别的 responseFormat 是一段JSON样例,告诉模型只输出 {"riskLevel": 1, "reason": "..."} 这样的格式;记忆提取的 responseFormat 则是一个JSON数组的示例,指导模型输出结构化的记忆条目列表。这两个 Prompt 都由 IntentRecognitionAgent 和 MemoryExtractionAgent 在创建时固定传入,调用方不需要关心输出格式的细节。
三、LLM:模型连接参数的封装与懒加载
LLM 基类的职责是封装模型连接所需的参数,并在实际调用时按需构建 LangChain4j 的模型实例。它不关心提示词,也不关心消息怎么组装,只管一件事——给一个配置,返回一个能调用的模型对象。
@Data
@NoArgsConstructor
@Accessors(chain = true)
public class LLM {
private String apiKey;
private String baseUrl;
private String modelName;
private Double temperature;
private Long timeoutSeconds;
private ChatLanguageModel model; // 同步模型(懒加载)
private StreamingChatLanguageModel streamingModel; // 流式模型(懒加载)
/**
* 获取同步模型实例(懒加载)
*/
public ChatLanguageModel getModel() {
if (model == null) {
model = buildModel();
}
return model;
}
/**
* 获取流式模型实例(懒加载)
*/
public StreamingChatLanguageModel getStreamingModel() {
if (streamingModel == null) {
streamingModel = buildStreamingModel();
}
return streamingModel;
}
/**
* 构建同步 ChatLanguageModel(子类可覆写定制)
*/
protected ChatLanguageModel buildModel() {
return OpenAiChatModel.builder()
.apiKey(apiKey)
.baseUrl(baseUrl)
.modelName(modelName)
.temperature(temperature != null ? temperature : 0.3)
.timeout(Duration.ofSeconds(timeoutSeconds != null ? timeoutSeconds : 60))
.build();
}
/**
* 构建流式 StreamingChatLanguageModel(子类可覆写定制)
*/
protected StreamingChatLanguageModel buildStreamingModel() {
return OpenAiStreamingChatModel.builder()
.apiKey(apiKey)
.baseUrl(baseUrl)
.modelName(modelName)
.temperature(temperature != null ? temperature : 0.3)
.timeout(Duration.ofSeconds(timeoutSeconds != null ? timeoutSeconds : 60))
.build();
}
}
这里有两个设计细节值得一说。第一个是懒加载——model 和 streamingModel 不是构造时创建的,而是在第一次调用 getModel() 或 getStreamingModel() 时才触发构建。这样做的好处是,如果某个Agent在Spring容器启动时就被实例化了,但直到第一次用户请求才需要真正连接模型,那么模型连接的初始化时机就被推迟到了真正需要的时候。对于单元测试或者启动时不想建立外部连接的场景也有好处。
第二个是 buildModel 和 buildStreamingModel 被声明为 protected,子类可以覆写。DeepSeekV4FlashLLM 没有覆写它们,因为 DeepSeek 的API和OpenAI完全兼容,直接用 OpenAiChatModel.builder() 就能连上 Sophnet 代理的 DeepSeek-V4-Flash 端点。但如果后续需要接入一个不是OpenAI兼容协议的模型(比如某国产模型的自定义SDK),就可以在子类覆写这两个方法,不影响其他任何代码。
DeepSeekV4FlashLLM 本身是一个很薄的子类,做的事就是在一个地方集中管理 DeepSeek 的连接参数——baseUrl、modelName、温度0.3、超时60秒——以及从 SecretsConfig 里解析apiKey的优先级逻辑(优先取 chat.api-key,为空回退 langchain.api-key)。
public class DeepSeekV4FlashLLM extends LLM {
private static final String BASE_URL = "https://www.sophnet.com/api/open-apis/v1";
private static final String MODEL_NAME = "DeepSeek-V4-Flash";
private static final double TEMPERATURE = 0.3;
private static final long TIMEOUT_SECONDS = 60;
public DeepSeekV4FlashLLM(SecretsConfig secretsConfig) {
setApiKey(resolveApiKey(secretsConfig))
.setBaseUrl(BASE_URL)
.setModelName(MODEL_NAME)
.setTemperature(TEMPERATURE)
.setTimeoutSeconds(TIMEOUT_SECONDS);
}
private static String resolveApiKey(SecretsConfig secretsConfig) {
String chatApiKey = secretsConfig.getChat().getApiKey();
if (chatApiKey != null && !chatApiKey.isBlank()) {
return chatApiKey;
}
return secretsConfig.getLangchain().getApiKey();
}
}
全部参数都常量化,不需要调用方传任何东西,只是一个简单的继承加配置注入,零行重复代码。
四、Agent:Prompt与LLM的组合调度
Agent 是整个框架的「组装车间」——它把一个 Prompt 和一个 LLM 组合在一起,对外提供四层调用方法,从最抽象的「给一句话,拿回一个回答」到最底层的「直接传 ChatMessage 列表调模型」,覆盖了不同场景的需求。
@Getter
public class Agent {
private final Prompt prompt;
private final LLM llm;
public Agent(Prompt prompt, LLM llm) {
this.prompt = prompt;
this.llm = llm;
}
// 第一层:纯文本输入输出 —— chat(String)
public String chat(String userMessage) {
return chat(userMessage, "");
}
// 第二层:带系统上下文 —— chat(String, String)
public String chat(String userMessage, String systemMessage) {
List<ChatMessage> messages = buildMessages(userMessage, systemMessage);
return chat(messages);
}
// 第三层:业务 Message 实体列表 —— chatWithMessages(List<Message>)
public String chatWithMessages(List<Message> messages) {
List<ChatMessage> chatMessages = new ArrayList<>();
chatMessages.add(prompt.buildSystemMessage());
for (Message msg : messages) {
if ("user".equals(msg.getRole())) {
chatMessages.add(UserMessage.from(msg.getContent()));
} else if ("assistant".equals(msg.getRole())) {
chatMessages.add(AiMessage.from(msg.getContent()));
}
}
return chat(chatMessages);
}
// 第四层:直接操作 LangChain4j 消息列表 —— chat(List<ChatMessage>)
public String chat(List<ChatMessage> messages) {
Response<AiMessage> response = llm.getModel().generate(messages);
return response.content().text();
}
// 流式版本的对应方法
public void streamChat(String userMessage, String systemMessage,
StreamingResponseHandler<AiMessage> handler) {
streamChat(buildMessages(userMessage, systemMessage), handler);
}
public void streamChat(List<ChatMessage> messages,
StreamingResponseHandler<AiMessage> handler) {
llm.getStreamingModel().generate(messages, handler);
}
// 消息组装 —— 系统提示词 + 上下文 + 当前用户消息
private List<ChatMessage> buildMessages(String userMessage, String history) {
List<ChatMessage> messages = new ArrayList<>();
messages.add(prompt.buildSystemMessage());
messages.add(AiMessage.from(history));
messages.add(UserMessage.from(userMessage));
return messages;
}
}
四层调用方法的设计是逐步下沉的。第一层 chat(String userMessage) 最简单,不传任何额外上下文,适合那些不需要知道用户是谁、不需要回忆说过什么的场景,比如意图识别——它只靠用户当前的一句话就能做分类。第二层 chat(String userMessage, String systemMessage) 会额外接收一段由Service层组装好的上下文文本,这段文本会被插入到系统提示词和当前用户消息之间作为「systemMessage」。在 ChatAgentService 中,这段上下文就是用户画像加上记忆压缩内容加上RAG知识检索结果的拼接结果。第三层 chatWithMessages(List<Message>) 接收的是业务层的 Message 实体列表,适合那些调用方已经有一批历史消息对象的场景,Agent内部会自动把 system 消息推进去、把 user 和 assistant 消息转成 LangChain4j 的类型。第四层 chat(List<ChatMessage>) 则是最底层的调用,调用方已经自己把消息全部组装好了,Agent只负责调模型返回结果。
buildMessages 私有方法的组装顺序很关键:系统提示词在最前面,然后是上下文(systemMessage),最后是当前用户消息。这个顺序保证了模型的注意力会先被系统提示词的角色约束框定,然后读完上下文信息,最后聚焦到用户当前的问题上。同步和流式版本都复用了这个方法,保证了两条路径下消息组装逻辑的一致。
流式版本的设计和同步版本是对称的——同样的参数结构,只是调用方式从 getModel().generate() 变成了 getStreamingModel().generate(handler),回调通过 StreamingResponseHandler 接口向外推送 token。上层调用方不需要关心底层模型是同步还是流式的,Agent 在这两层方法之间的衔接完全透明。
五、六个业务Agent:只写构造器,不写逻辑
六个业务Agent的实现都遵循同一个模式——继承 Agent,在构造器里调用 super(prompt, llm) 传入对应的 Prompt 子类和 LLM 子类,然后就结束了。每个Agent的代码不超过15行。
IntentRecognitionAgent 是一个有额外业务逻辑的特例,它重写了 recognize 方法来处理LLM返回的JSON清洗和解析。
@Component
public class IntentRecognitionAgent extends Agent {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
public IntentRecognitionAgent(SecretsConfig secretsConfig) {
super(new IntentRecognitionPrompt(), new DeepSeekV4FlashLLM(secretsConfig));
}
public IntentResult recognize(String userMessage) {
String raw = null;
try {
raw = chat(userMessage);
String json = extractJson(raw);
return OBJECT_MAPPER.readValue(json, IntentResult.class);
} catch (JsonProcessingException e) {
log.warn("意图识别 JSON 解析失败,原始返回: {}", raw);
return new IntentResult(1, raw);
}
}
private String extractJson(String raw) {
// 从 LLM 可能包裹 markdown 代码块的输出中提取纯 JSON
if (raw == null || raw.isBlank()) return "{}";
String text = raw.trim();
int start = text.indexOf('{');
int end = text.lastIndexOf('}');
if (start >= 0 && end > start) return text.substring(start, end + 1);
return text;
}
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public static class IntentResult {
@JsonAlias({"risk_level", "RiskLevel", "riskScore", "level"})
private int riskLevel;
@JsonAlias({"Reason", "description", "desc"})
private String reason;
}
}
IntentRecognitionAgent 之所以多写了业务逻辑,是因为意图识别不是一个简单的文本对文本的对话——它要求LLM返回结构化的JSON,Agent需要负责解析和容错。解析失败时不抛异常,返回一个 riskLevel=1 的默认结果把用户消息兜底送到医疗链路,让整个流程不至于断掉。这个容错设计在前一篇 ChatAgentService 的文章里详细讨论过,不再展开。
其余五个Agent全是纯构造器的写法。以 GeneralAgent 和 MemoryExtractionAgent 为例:
@Component
public class GeneralAgent extends Agent {
public GeneralAgent(SecretsConfig secretsConfig) {
super(new GeneralPrompt(), new DeepSeekV4FlashLLM(secretsConfig));
}
}
@Component
public class MemoryExtractionAgent extends Agent {
public MemoryExtractionAgent(SecretsConfig secretsConfig) {
super(new MemoryExtractionPrompt(), new DeepSeekV4FlashLLM(secretsConfig));
}
}
MedicalConsultationAgent、MemoryCompressionAgent、OnboardingAgent 的实现跟上面完全一致,只是传入的 Prompt 子类不同。六个Agent全部用 @Component 注册为Spring Bean,在 Service 层通过构造器注入使用。
Agent子类的内部没有存储任何状态——所有上下文由Service层在每次调用时通过参数传入。这使得Agent天然线程安全,不需要考虑并发问题。但这也意味着多轮对话的状态管理职责完全落在了Service层身上,Agent本身不「记得」上一轮说了什么。如果后续需要支持那种Agent自己管理多轮对话状态的场景,就得在 Agent 里引入 List<ChatMessage> 作为实例字段来保存历史消息,这会让Agent从无状态的函数变成有状态的对象,需要在Service层管理Agent的生命周期——不过目前所有场景都是一次调用完成一次应答,无状态足够用了。
六、六个Prompt子类:每个Prompt只做一件事
六个 Prompt 子类的组织方式跟 Agent 是一一对应的,每个 Prompt 封装了该业务场景下的系统提示词模板。它们的实现模式极其统一:定义一个私有的静态模板字符串常量,在构造器里调用 setSystemPrompt 注入,需要约束输出格式的再加一句 setResponseFormat。
GeneralPrompt 是最简单的一类——只定义了角色和职责范围,告诉模型你就是一个陪伴老人聊天的助手,可以聊天气、聊兴趣爱好、聊生活琐事,但不要涉及医疗话题。这个边界划分很重要,因为通用Agent没有知识库检索,如果在回答中不小心给出医疗建议,后果可能很严重。限制它的职责范围是上游意图识别兜底之外的又一层保护。
MedicalConsultationPrompt 则完全不同,它在提示词中明确写入了核心原则——回复速度优先、口语化表达、不输出思考链、高风险强调就医、附带免责声明。医疗场景下的回答质量和安全问题远比通用场景复杂,提示词里每一条约束都是经过反复测试后加进去的,比如「不要将原始引用块直接插入主要答案中」这一条就是因为早期版本里模型会直接把知识库切片的原文大段贴进回答,阅读体验很差。
IntentRecognitionPrompt 和 MemoryExtractionPrompt 是两个需要结构化输出的 Prompt,它们在 systemPrompt 里详细列出了分类标准和提取规则,在 responseFormat 里给出了严格的JSON样例。IntentRecognitionPrompt 把风险等级从0到3的定义写得很详细——0级的具体场景列了七八条,3级把「自杀」「心梗」「休克」这些关键词都枚举了出来,1级和2级也各写了触发条件。这种精细度的提示词是反复调优的结果,一开始只写了一个粗略的分类标准,但模型经常会碰到模糊输入就乱分级,后来逐步把场景具体化、把边界规则明确化,准确率才稳定下来。
MemoryCompressionPrompt 是所有Prompt里最长的,因为压缩任务对指令的精确性要求非常高——什么信息该保留、什么该丢弃、保留到什么粒度、输出什么格式,任何一个点没说清楚,模型就会丢关键信息或者塞一堆废话。它的提示词分成了「需要保留的信息类型」和「输出要求」两大部分,前者按基础信息、健康状况、用药情况、生活习惯、偏好与诉求五个子类展开,每个子类都给了具体示例,后者则规定了字数上限、分段方式、禁止加引导语等细则。
OnboardingPrompt 的角色定义跟其他Prompt不太一样——它不是问答型的,而是引导型的。提示词里强调了自然对话的感觉,要求「像朋友聊天一样自然,不要像问卷调查一样审问」「每次只引导1个话题」「先回应用户刚才说的话,再自然过渡到下一个话题」。这些约束对于老年人用户的建档体验至关重要,如果建档过程像是填表格,用户的完成率会非常低。
六个Prompt子类总共的代码量加在一起大概也就两百多行,全都是语言描述的模板,没有任何Java逻辑。但正是这些模板决定了每个Agent的行为边界和回答质量,在整个框架中,代码只是骨架,提示词才是灵魂。
七、为什么是面向对象而不是策略模式
看完整个框架之后可能会有一个疑问:Prompt 和 LLM 这两个对象其实都可以直接用配置或者工厂模式来管理,没必要建一堆子类。比如用一个 PromptFactory,根据类型字符串返回对应的提示词模板,用一个 LLMFactory 返回模型实例,Agent 的实例化也可以全收敛到Spring的配置类里。
选择面向对象的继承方案,而不是策略或工厂模式,主要是三个考虑。第一个是扩展的零成本——新增一个Agent只需要新建两个类(一个Agent子类、一个Prompt子类),两个类的代码量加起来不超过30行,不需要动任何工厂类、配置类或枚举。第二个是可读性好——看一个Agent子类的构造器就知道它用了什么Prompt和什么LLM,看一个Prompt子类的构造器就知道它的提示词模板长什么样,逻辑不乱跳。第三个也是最重要的,这个框架被设计为对内使用而非对外SDK,不需要考虑运行时动态切换策略的场景。所有Agent都是Spring启动时确定的,每个Agent固定用一套Prompt和一套LLM参数,不会有「同一个Agent实例在某次调用时用PromptA、下一次调用时换成PromptB」的需求,所以工厂动态创建带来的灵活性在这个场景下是用不上的。
如果未来需要支持「同一个Agent动态切换Prompt」——比如根据对话轮次使用不同语气的提示词——那工厂模式就更合适。但目前没有这个需求,保持简单就好。
八、最后
这个Agent框架的核心思路其实只有六个字:组合优于继承。但这里说的「组合优于继承」不是指不用继承——Agent、Prompt、LLM 之间的三层关系本身就是继承链条——而是指 Prompt 和 LLM 作为组件被组合进 Agent,而不是被硬编码在Agent的方法里。这种组合让Prompt和LLM可以独立变化,一个Prompt变了不影响LLM,一个LLM参数调了不影响Prompt,新增Agent也只是新增一对组合。
回过头看,整个框架最花时间的地方反而不是写Agent的代码——Agent基类和子类的代码量其实很少——而是Prompt的打磨。每个Prompt从初版到稳定版本至少改了五六轮,改动内容不是代码而是文字描述,但这个文字描述的质量直接决定了Agent回答的质量。这大概是用大模型做应用开发最有意思的地方:最重的工程活不在代码里,而在提示词里。
更多推荐



所有评论(0)