Skip to Content
Hi-Agent v1.0 · 全新上线 · 一门关于 Agent 工程的系统课程
课程01 · Chat1.1 聊起来先

1.1 聊起来先

从本小节开始,整个Hi-Agent的实践便开始了,这是一个Java项目,至于为什么这个时代了还用Java 别纠结,我只是因为熟悉,和想挑战下用Java做复杂Agent,课程里的内容是相通的~加油

无论一个 Agent 系统看起来多么复杂,它的核心入口始终离不开 Chat。

当然,这里的 Chat 已经不再只是“人类在对话框里发一句话”。在今天的 Agent 系统中,它可以来自用户输入,也可以来自定时任务触发、心跳触发、Webhook 触发,甚至来自另一个 Agent 的调用。

但本质上,这些触发方式并没有区别。

只要你想唤醒一个 Agent,就必须给它一个入口;只要 Agent 要开始工作,就一定需要接收一条“消息”。这条消息可能是一句自然语言,也可能是一段结构化任务,还可能是系统在某个时间点自动生成的指令。

所以,Chat 是 Agent 的起点。

因此,在这一小节里,我们不会一上来就谈复杂的工具调用、多 Agent 协作或者长期记忆,而是先回到最基础、也最关键的地方:

搭建项目框架,准备 Mock 服务,启动项目,然后让 Agent 真正“聊起来”。

1.1.1 搭建脚手架

这里我默认大家都是有Java基础的伙伴,新建一个 Maven 项目并不是多么复杂的事情,如果不会,也可以让AI Vibe一下; 而你,我的伙伴,请把重点放在我都实现了哪些能力上

初始化项目的结构如下:

项目初始目录结构
λ ~/Desktop/jaguarliu/core/hi-agent-java/ tree . ├── pom.xml └── src ├── main ├── java └── com └── rookie └── stack └── Main.java └── resources └── test └── java 9 directories, 2 files

接下来就是安装依赖,我们这里引入openai-java依赖

pom.xml — openai-java 依赖
<dependency> <groupId>com.openai</groupId> <artifactId>openai-java</artifactId> <version>4.33.0</version> </dependency>

这里注意一个神奇的问题,openai-java这个包需要从maven官方仓库拉取,如果你配置了阿里云仓库,那需要再加一下中央仓库 在阿里云仓库里,只有4.9.0版本以前的openai-java

openai-java 是 OpenAI 官方提供的 Java 客户端库,主要用于在 Java 项目中调用 OpenAI API。但在实际工程里,我们也可以把它理解成一个 OpenAI 兼容协议的适配器。

从 ChatGPT 火出圈之后,OpenAI 的接口协议逐渐变成了大模型应用开发中的一种事实标准。后续很多大模型厂商都开始兼容 OpenAI API 格式,包括我们常用的国内大模型服务商,比如 Qwen、DeepSeek、MiniMax、GLM 等。

这意味着,只要这些厂商提供了 OpenAI 兼容接口,我们就可以通过配置不同的 base_url 和 api_key,直接使用 openai-java 来调用它们的模型服务。

所以,在这一节里,我们选择 openai-java,并不只是为了调用 OpenAI 官方模型,更重要的是借助它统一不同模型厂商的接入方式。

接下来,我们还需要新建一个配置文件.env,用于存储我们的模型 API Key 和 Base URL。在我们的项目里,我基本上都会使用DeepSeek来做演示,当然如果你有其他的模型接入,按需配置即可。

.env — 模型接入配置
OPENAI_BASE_URL=https://api.deepseek.com OPENAI_API_KEY=sk-用自己的API-KEY OPENAI_MODEL=deepseek-v4-pro

如果你要把代码传到github之类的云端仓库里去,那请一定记得把.env文件加入到.gitignore里面,否则你的API Key会被泄露出去

接下来,我们还需要一些辅助的依赖:

pom.xml — dotenv-java 依赖
<dependency> <groupId>io.github.cdimascio</groupId> <artifactId>dotenv-java</artifactId> <version>3.2.0</version> </dependency>

1.1.2 实现配置解析和聊起来

接下来,我们想和AI聊起来,其实特别简单,和大模型的交互,本质上还是请求一个远端的API,和我们日常去访问其他的服务,没有什么本质上的差异;

因此,流程上和HTTP请求也没有什么特别大的差异,新建一个客户端(Client)然后构造特定的参数,发起特定的请求,最后解析响应即可。

1.1.2.1 OpenAiConfig 配置解析

我们先来解析一下我们的配置文件,从环境变量中获取 OpenAI_BASE_URL、OPENAI_API_KEY、OPENAI_MODEL 等参数。

OpenAiConfig.java — 配置解析 record(约 45 行)
import io.github.cdimascio.dotenv.Dotenv; import java.nio.file.Path; public record OpenAiConfig(String baseUrl, String apiKey, String model) { public OpenAiConfig { baseUrl = requireNonBlank(baseUrl, "baseUrl"); apiKey = requireNonBlank(apiKey, "apiKey"); model = requireNonBlank(model, "model"); } public static OpenAiConfig load() { return load(Path.of(".")); } public static OpenAiConfig load(Path directory) { Dotenv dotenv = Dotenv.configure() .directory(directory.toAbsolutePath().toString()) .filename(".env") .ignoreIfMissing() .load(); return new OpenAiConfig( requiredConfig(dotenv, "OPENAI_BASE_URL"), requiredConfig(dotenv, "OPENAI_API_KEY"), requiredConfig(dotenv, "OPENAI_MODEL")); } private static String requiredConfig(Dotenv dotenv, String key) { String value = dotenv.get(key); if (value == null || value.isBlank()) { throw new IllegalStateException("Missing required config: " + key); } return value.trim(); } private static String requireNonBlank(String value, String fieldName) { if (value == null || value.isBlank()) { throw new IllegalArgumentException(fieldName + " must not be blank"); } return value.trim(); } }

OpenAiConfig被设计成一个 record,因为它本质上只是一个只读的数据载体,没有业务状态变化。它只有三个字段:baseUrl、apiKey、model。 它在紧凑构造器里做了统一校验,确保三个字段都不是空值或空白字符串。这里选择“创建时即校验”,而不是等到真正发请求时再报错,是为了让问题尽可能早暴露出来。

具体实现上,用了 dotenv-java。通过 Dotenv.configure() 指定目录和文件名,然后在代码中逐个读取 OPENAI_BASE_URL、OPENAI_API_KEY、OPENAI_MODEL。如果某个配置缺失,就抛出明确的异常,比如 “Missing required config: OPENAI_MODEL”。这样用户看到报错时,能立刻知 道缺的是哪一个键,而不是只得到一个模糊的空指针或认证失败。

当然这里的配置我们后续需要扩展的还有很多,比如模型参数、超时时间、重试策略等,用到的时候,我们再来扩展。

1.1.2.2 OpenAiChatClient聊起来

OpenAiChatClient.java — 单轮 Chat 封装(约 50 行)
public final class OpenAiChatClient implements AutoCloseable { private final OpenAIClient client; private final String model; public OpenAiChatClient(OpenAIClient client, String model) { this.client = Objects.requireNonNull(client, "client must not be null"); this.model = requireNonBlank(model, "model"); } public static OpenAiChatClient fromEnvFile() { OpenAiConfig config = OpenAiConfig.load(); OpenAIClient client = OpenAIOkHttpClient.builder() .baseUrl(config.baseUrl()) .apiKey(config.apiKey()) .build(); return new OpenAiChatClient(client, config.model()); } public String chat(String userPrompt) { String normalizedPrompt = requireNonBlank(userPrompt, "userPrompt"); ChatCompletion completion = client.chat() .completions() .create(ChatCompletionCreateParams.builder() .model(model) .addUserMessage(normalizedPrompt) .build()); return completion.choices().stream() .findFirst() .map(ChatCompletion.Choice::message) .flatMap(message -> message.content()) .filter(content -> !content.isBlank()) .orElseThrow(() -> new IllegalStateException("AI response did not contain assistant content")); } @Override public void close() { client.close(); } private static String requireNonBlank(String value, String fieldName) { if (value == null || value.isBlank()) { throw new IllegalArgumentException(fieldName + " must not be blank"); } return value.trim(); } }

OpenAiChatClient是真正执行 AI 调用的类。它内部只保留两项状态:OpenAIClient client 和 String model,分别代表 SDK 客户端实例和当前模型名。

当然,这里没有把客户端创建逻辑写死在构造器里,而是保留了一个可注入构造器这样做的原因是可测试性:测试时可以直接传入 mock 的 OpenAIClient,不需要真的连网络,也不依赖真实 .env。

为了让业务代码使用起来更简单,又额外提供了一个静态工厂方法,这个方法内部先调用 OpenAiConfig.load() 拿到配置,再用 OpenAIOkHttpClient.builder() 设置 baseUrl 和 apiKey,最后构造 OpenAiChatClient。

这样,调用方既可以直接用默认方式启动,也可以在需要时手动注入自定义客户端。

1.1.2.3 聊起来

有了前面的封装,发起一个Chat那就再简单不过了:

OpenAiChatClientLiveTest.java — 首个联调测试
class OpenAiChatClientLiveTest { private static final Logger log = LoggerFactory.getLogger(OpenAiChatClientLiveTest.class); @Test @EnabledIfSystemProperty(named = "openai.live.test", matches = "true") void performsRealChatCompletion() { try (OpenAiChatClient client = OpenAiChatClient.fromEnvFile()) { String reply = client.chat("你好呀~"); System.out.println(reply); assertNotNull(reply); assertFalse(reply.isBlank()); } } }

手动在ide里启动单元测试,或者你在终端里执行 mvn test 即可运行单元测试,一切顺利的话,你会得到如下输入:

终端输出 — 首次 Chat 成功
你好呀!👋 很高兴见到你~ 我是DeepSeek,一个热心的AI助手。无论你想聊天、问问题、找资料,还是需要帮忙写作、分析、头脑风暴,我都随时准备着! 今天有什么我可以帮你的吗?或者就是想随便聊聊也完全没问题~ 😊 Process finished with exit code 0

我们第一个Chat就跑通了,但是,现在我们的代码,你运行一次测试,就得去改一次测试里的提示词,这显然不是我们期望的,我需要让他支持对话,哪怕只是 现在命令行窗口里输入一个提示词,他也能返回一个回复。我可以继续和他接着聊。

1.1.3 支持对话

我们要实现的对话部分也没有那么复杂,当Main启动,我们就开启一个无限等待的循环,等待用户的输入,当用户输入之后,我们就调用OpenAiChatClient的chat方法,获取回复,最后打印回复。 直到用户输入exit,或者停止了Main函数,我们才退出循环。

当然在这里,我们还需要简单处理下上下文呢,既然是多论对话,那么我们就需要将完整的历史会话都传递给模型,让模型可以持续的在一个上下文里和我们进行交互,你甚至可以简单的理解为,这就是Context Engineering 的雏形。

1.1.3.1 先让 OpenAiChatClient 支持完整历史消息

前面提到了,多轮对话的本质不是“循环读取输入”,而是“每一轮请求都带上下文”。

如果 OpenAiChatClient 还只能接收一个 String userPrompt,那么后面就算把命令行循环写出来,也只是不断发单轮请求,根本不是真正的多轮聊天。所以第一步必须先解决这个问题: 客户端要能接收一组有顺序的历史消息。

为了表达历史消息,先引入了两个内部模型:

ChatRole.java — 角色枚举
enum ChatRole { USER, ASSISTANT }
ChatMessage.java — 消息 record
record ChatMessage(ChatRole role, String content) { ChatMessage { Objects.requireNonNull(role, "role must not be null"); if (content == null || content.isBlank()) { throw new IllegalArgumentException("content must not be blank"); } content = content.trim(); } }

ChatRole 只有两个值:

  • USER
  • ASSISTANT

之所以只保留这两个角色,是因为当前我们目标只是“命令行多轮对话”的最小版本。 先做最少模型,比一开始就把 SYSTEMTOOLDEVELOPER 全部引入更清楚。

ChatMessage 则负责保存一条内部消息:

  • role
  • content

并且在构造时完成最基本的校验:

  • role 不能为空
  • content 不能为空白
  • content 自动 trim()

1.1.3.2 扩展OpenAiChatClient

扩展后的OpenAiChatClient保留了原来的单轮接口:

单轮接口(保留)
public String chat(String userPrompt)

同时新增了多轮接口:

多轮接口(新增)
public String chat(List<ChatMessage> messages)

这里保留旧接口而不是直接删掉,有两个原因:

  1. 不破坏前面已经写好的单轮内容
  2. 让多轮能力成为对单轮能力的平滑扩展

单轮方法现在只是把输入包装成一条 USER 消息,再复用多轮方法。这样逻辑只有一份,不会出现两套不同实现。

真正的多轮处理逻辑在

chat(List<ChatMessage>) — 多轮请求完整实现
public String chat(List<ChatMessage> messages) { if (messages == null || messages.isEmpty()) { throw new IllegalArgumentException("messages must not be empty"); } ChatCompletionCreateParams.Builder builder = ChatCompletionCreateParams.builder().model(model); for (ChatMessage message : messages) { if (message.role() == ChatRole.USER) { builder.addUserMessage(message.content()); } else if (message.role() == ChatRole.ASSISTANT) { builder.addAssistantMessage(message.content()); } else { throw new IllegalArgumentException("Unsupported chat role: " + message.role()); } } ChatCompletion completion = client.chat() .completions() .create(builder.build()); return completion.choices().stream() .findFirst() .map(ChatCompletion.Choice::message) .flatMap(message -> message.content()) .filter(content -> !content.isBlank()) .orElseThrow(() -> new IllegalStateException("AI response did not contain assistant content")); }
  • 遍历 List<ChatMessage>
  • USER 转成 builder.addUserMessage(...)
  • ASSISTANT 转成 builder.addAssistantMessage(...)
  • 再统一调用 openai-java

1.1.3.3 引入 OpenAiChatSession 管理会话状态

当客户端已经能处理完整消息列表之后,第二步才轮到会话状态。新增会话管理类 OpenAiChatSession

OpenAiChatSession.java — 会话状态管理(约 25 行)
public final class OpenAiChatSession { private final OpenAiChatClient client; private final List<ChatMessage> history = new ArrayList<>(); public OpenAiChatSession(OpenAiChatClient client) { this.client = Objects.requireNonNull(client, "client must not be null"); } public String send(String userInput) { ChatMessage userMessage = new ChatMessage(ChatRole.USER, userInput); List<ChatMessage> requestMessages = new ArrayList<>(history); requestMessages.add(userMessage); String reply = client.chat(requestMessages); history.add(userMessage); history.add(new ChatMessage(ChatRole.ASSISTANT, reply)); return reply; } List<ChatMessage> history() { return List.copyOf(history); } }

OpenAiChatSession内部只保留了:

  • OpenAiChatClient client
  • List<ChatMessage> history

对外只暴露一个核心方法:

send — 会话对外接口
public String send(String userInput)

这方法的流程很简单:

  1. 把当前用户输入包装成 ChatMessage(USER, userInput)
  2. 基于旧历史复制出一份临时请求列表
  3. 把当前用户消息加到临时请求列表末尾
  4. 调用 client.chat(requestMessages)
  5. 调用成功后,再把 user 和 assistant 两条消息写回正式历史

“成功后再写历史”这是这一层最关键的设计点。如果一开始就把用户消息写进正式历史,一旦请求失败,就会留下这种不一致状态:

  • 用户已经说了一句话
  • assistant 没有回应

这会让下一轮上下文变得奇怪,也会让调试变复杂。

所以这里采用了更稳妥的策略:
先构造临时请求,调用成功之后,再把这一轮 user 和 assistant 一起提交到历史中。

1.1.3.4 最后把能力接到 Main

Main只负责交互流程:

  • 创建 OpenAiChatClient
  • 创建 OpenAiChatSession
  • 打印启动提示
  • 循环读取一行输入
  • 空行直接跳过
  • 输入 exit 时退出
  • 其余输入交给 session.send(...)
  • 打印 AI 回复
  • 如果本轮请求失败,只打印错误,不直接杀掉整个进程
Main.java — 命令行交互入口(约 35 行)
public class Main { public static void main(String[] args) { try (OpenAiChatClient client = OpenAiChatClient.fromEnvFile(); BufferedReader reader = new BufferedReader(new InputStreamReader(System.in))) { OpenAiChatSession session = new OpenAiChatSession(client); System.out.println("Chat started. Type 'exit' to quit."); while (true) { System.out.print("You: "); String line = reader.readLine(); if (line == null) { System.out.println(); break; } String input = line.trim(); if (input.isEmpty()) { continue; } if ("exit".equals(input)) { break; } try { String reply = session.send(input); System.out.println("AI: " + reply); } catch (Exception exception) { System.out.println("AI error: " + exception.getMessage()); } } } catch (Exception exception) { System.err.println("Failed to start chat: " + exception.getMessage()); } } }

这里刻意保持 Main 很薄,不让它保存历史,也不让它自己拼 OpenAI SDK 请求。 这样入口层只做“交互编排”,不会和会话逻辑耦在一起。

完成之后,启动Main类可以像下面这样在命令行里连续对话:

实际运行对话实录(点击展开查看完整回复)
Chat started. Type 'exit' to quit. You: 你好,我是jaguar,你介绍下你自己 AI: 你好,Jaguar!很高兴认识你。 我是 **DeepSeek**,一个由深度求索公司倾心打造的 AI 助手。你可以把我理解为一个住在你手机或电脑里的智能伙伴。 简单介绍下我自己: - **我的核心能力** - **海量“内存”**:我拥有 1M 的上下文处理能力,可以一次性帮你读完像《三体》三部曲那么大体量的书籍,并回答其中的细节。 - **多才多艺**:我擅长文字交流、知识问答、创意写作、代码编写、逻辑推理,甚至还能陪你闲聊解闷。 - **文件处理**:虽然我不支持多模态图像识别,但我能读取你上传的图片、PDF、Word、Excel、PPT 等文件里的文字信息,帮你整理和分析。 - **联网搜索**:如果你需要最新的信息,记得在网页端或 App 上手动打开联网搜索按键,我就能帮你梳理全网资讯。 - **我的性格特点** - **完全免费**:目前没有任何收费计划,你可以尽情使用。 - **热情细腻**:我会尽力用温暖、友好的方式和你交流。 - **支持语音**:在 App 端(官方应用商店可下载)可以直接用语音和我聊天,很方便。 怎么样?如果你有任何想聊的话题、想解决的问题,或者只是想找个伴说说话,随时可以丢给我。 有什么我可以立刻为你效劳的吗? You: 你知道今天是几月几号吗 AI: 抱歉,Jaguar,我目前无法直接知道确切的日期。作为一个AI模型,我没有内置的实时时钟,也无法主动获取系统时间。 不过,如果你需要知道今天的日期,可以**开启联网搜索功能**,我就能帮你查到准确的时间。或者你也可以直接告诉我今天是几月几号,我很乐意帮你安排日程、计算日期,或者做任何与时间相关的规划! You: 你还记得我是谁吗 AI: 当然记得,Jaguar!在这一轮对话里,你一开始就告诉我你是 **jaguar**,所以我一直记着你的名字。 不过,要诚实地告诉你,我的记忆只在当前这次聊天中有效。一旦对话结束、窗口关闭或会话超时,我就会忘记这次聊天的所有细节。下次你再来找我时,我可能就认不出你了,除非你再次告诉我你的名字。 如果你希望我长期记住一些关键信息(比如你的偏好、重要事项等),可以随时把你希望我记的东西整理成文本发给我,然后由你自己保存下来,下次需要时再复制给我——我可以帮你把这些要点整理好。这样你就能在未来的对话中快速让我“恢复记忆”了。 有什么想聊的吗?我随时在。 You:

1.1.3.5 总结

到这里,我们已经完成了 Hi-Agent 的第一步:让项目真正聊起来。

这一小节我们没有一上来就做复杂的 Agent Loop,也没有急着接入 Tool、Memory 或 Multi-Agent,而是先把最基础的 Chat 入口搭了起来。因为对任何 Agent 来说,能被唤醒、能接收消息、能返回结果,都是后续一切能力的前提。

在代码层面,我们完成了几件事:

  • 首先,用 .env 管理模型配置,把 OPENAI_BASE_URL、OPENAI_API_KEY 和 OPENAI_MODEL 从代码中拆出来,避免把敏感信息写死在程序里。
  • 然后,封装了 OpenAiConfig,专门负责配置读取和校验。这样一旦配置缺失,程序可以在启动阶段直接报出明确错误,而不是等到请求模型时才出现一堆难以定位的问题。
  • 接着,我们实现了 OpenAiChatClient,把和 OpenAI 兼容协议交互的逻辑集中封装起来。业务层不需要关心 SDK 的具体调用细节,只需要调用 chat(…) 方法即可。
  • 在此基础上,我们又进一步让 OpenAiChatClient 支持完整历史消息列表,使它不再只能进行单轮问答,而是具备了多轮对话的基础能力。
  • 最后,通过 OpenAiChatSession 管理会话历史,再把它接入 Main 命令行入口,实现了一个最小可用的命令行聊天程序。

虽然这个版本还非常简单,但它已经具备了一个 Agent 系统最早期的雏形:

  • 有消息入口;
  • 有模型客户端;
  • 有配置管理;
  • 有多轮历史;
  • 有最基础的上下文传递;
  • 有命令行交互;
  • 也有简单的异常处理。

更重要的是,我们已经开始触碰到 Agent 工程里一个非常关键的问题:模型并不会天然记住一切,多轮对话依赖的是我们主动把历史消息重新传给它。

这就是 Context Engineering 最早的影子。

当前版本的上下文管理还很粗糙,只是简单地把完整历史消息全部带上。它可以帮助模型在当前会话里记住“我是谁”“刚才说过什么”,但它也有明显问题:历史越长,请求越重;无关信息越多,模型越容易分心;一旦上下文超过模型限制,还需要压缩、裁剪和摘要。

这些问题,我们后面会一步一步解决。

但现在,先别急。

这一节的目标已经完成: 我们搭好了项目骨架,也让 Agent 开口说话了。

接下来,我们就可以在这个最小 Chat 系统的基础上,继续拆解 Agent 的核心概念,逐步把它从一个“能聊天的程序”,推进成一个真正“能做事的 Agent”。

本节源码b09d501feat: add OpenAI CLI multi-turn chat example本节对应的完整提交:新增 ChatRole / ChatMessage / OpenAiChatSession,并扩展 OpenAiChatClient 支持完整历史消息,Main 接入多轮命令行交互。

下一节1.2 核心概念