本文介绍了大模型Agent的基本概念和ReAct推理范式,通过实际案例和代码示例,详细讲解了如何基于LangChain4j实现ReAct范式的Agent。文章还探讨了Agent工程实践中的成本与权限控制、Token消耗控制、对话上下文管理以及海量对话记录存储等问题,为读者提供了实用的技巧和解决方案。
在 AI 领域刚出来的时候,LLM 本身只会“输入→输出”这样简单的流程,但是还没有方式让它像人一样多步骤、有策略地完成任务。因此Agent工程化的目的,就是为了让原本的LLM具备像人一样拥有综合性的解决能力。
ReAct(Reasoning + Acting)
传统 LLM 调用是:你问 → 它答,而在ReAct推理范式的设想中,LLM的问答变成了如下流程:
Thought(思考)→ Action(调用工具)→ Observation(观察结果)→ Thought → Action → ... → Answer举个例子:
- Thought: 我需要知道北京今天的天气
- Action: 调用天气 API,参数 city=北京
- Observation: 晴,25°C,微风
- Thought: 温度舒适,我可以给出建议
- Answer: 建议穿薄长袖或T恤……
在上述案例的关键点中,有以下几个重点步骤:
- “Reasoning” 让它显式写出推理过程(Chain-of-Thought 的延伸)
- “Acting” 让它能调用外部工具(搜索、计算器、数据库、代码执行)
- 通过 Observation 把真实世界的结果反馈回模型,纠正幻觉
在场景的AI工程实践中:这是最基础、最常用的 Agent 范式。LangChain、LlamaIndex 里的 AgentExecutor 本质就是 ReAct 循环。它适合步骤数不确定、需要边做边看的任务。
基于LangChain4j实现ReAct范式
在大多数的实际应用场景中,基于ReAct推理范式的Agent工程是最常见的,例如下边这段代码:
// 一个简单的工具,供 Agent 调用public class WeatherTool { @Tool("查询指定城市的天气") public String getWeather(@P("城市名") String city) { return city + ":晴,25°C,微风"; }} // 模型(以 OpenAI 为例,也可换 Ollama、Qwen 等)ChatLanguageModel model = OpenAiChatModel.builder() .apiKey(System.getenv("OPENAI_API_KEY")) .modelName("gpt-4o-mini") .build(); //定义一个助手 interface Assistant { String chat(String userMessage);} //助手实现类,然后注入相关的工具,模型,对话上下文类Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(model) .tools(new WeatherTool()) // 注册工具 .chatMemory(MessageWindowChatMemory.withMaxMessages(20)) .build(); String answer = assistant.chat("北京今天适合穿什么?");System.out.println(answer);上面的这段代码是我用LangChain4j实现的一个简单的基于天气查询的Agent。这里你会发现,LangChain4j官方1.8.0版本提供的这套写法,其实和最早期LangChain4j的写法是有非常巨大区别的。
这套实现最方便的地方在于:只要把工具交给 AiService,它内部就自动跑 ReAct 循环(Thought → Tool Call → Observation → … → Answer)。
它背后做的事(等价于手写 ReAct):
- 把工具描述塞进 prompt
- 模型返回 “我要调用 getWeather(北京)”
- 框架执行工具,把结果作为 Observation 再喂回模型
- 循环直到模型给出最终答案
但是可能有人会问,这里面的循环逻辑怎么控制边界,是否可以手动控制ReAct的循环逻辑呢?当然LangChain4j其实最初出现在市面上的时候就有提供这方面的能力,来看下边这段代码:
String prompt = """ 你可以使用工具。按以下格式回答: Thought: 你的思考 Action: 工具名[参数] Observation: 工具返回 ...(循环) Final Answer: 最终答案 可用工具:getWeather(city) 问题:%s """.formatted(question); while (true) { String output = model.generate(prompt); if (output.contains("Final Answer:")) return output; // 解析 Action,执行工具,拼接 Observation 回 prompt String observation = executeTool(output); prompt += "/n" + output + "/nObservation: " + observation;}上述代码片段中,就是通过每次调用工具,判断调用工具返回的结果是否符合预期,从而控制循环的边界逻辑了。
到这里你已经入门了如何基于LangChain4j这类框架去实现一个简单的ReAct范式的Agent了。那么下边我们来引入更多实际落地过程中会遇到的问题点。
成本与权限控制
在 LangChain4j 的 ReAct 实现里,框架帮你处理了“循环调用工具”的基本骨架,但生产环境下的稳定性、成本与答案质量,取决于你如何设计工具、约束循环、处理异常以及管理上下文。下面从工程落地角度,挖掘几个真正实用的技巧。
工具最大调用次数
在 LangChain4j 中,maxSequentialToolsInvocations 用于限制 单次 LLM 请求中可连续调用工具的最大次数,防止模型陷入无限循环调用或过度依赖工具的情况。这一参数在涉及 多轮工具调用(如先调用搜索工具,再调用数据处理工具)时尤为重要。
Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(model) .tools(new YourTool()) .maxSequentialToolsInvocations(8) // 最大上限 .build();最大耗时控制
底层 ChatLanguageModel 必须设置超时。网络抖动或模型服务响应慢会拖垮整个 Agent 流程。在构建模型时显式传入 timeout 参数:
OpenAiChatModel model = OpenAiChatModel.builder() .apiKey(...) //单次LLM对话的超时时间限制 .timeout(Duration.ofSeconds(30)) .build();除了控制模型对话的耗时之外,在实际企业级应用中,可能还要考虑调用LLM服务的整个完整链路。例如我就增加遇到过这么一个流程:
用户请求->系统集群的统一出口CLB->内部Nginx->应用服务->LLM厂商接口
当时我们的业务上线后,遇到一些长耗时的任务就会表现为:下游对话中断。后来调整了LLM的对话超时参数后还是不见效,于是向上游排查,才发现我们的统一对外CLB针对单个Http请求的timeout设置是60s。最后通过调整统一对外CLB的timeout时间后才恢复正常。
可能有人会感觉,timeout这个参数如何评估真的很重要吗?是否可以拍脑袋就写个数值呢?
其实结合我过往的经历,建议这块需要结合实际业务场景去设置,例如你的Agent是做报表绘制和数据查询类业务场景的,那么一般这类场景的耗时最多在15min内可能就完毕了。但是有一些Agent是要做视频渲染/剪辑/导出 这类工作的,那么可能会耗费上1-3小时不等,一旦因为timeout中断,就很可能会导致用户投诉甚至流失客户这类不良现状。所以在设置timeout参数的时候,请三思。
控制Token消耗
在 LangChain4j 的 ReAct 循环中,Token 消耗的计算分为两种场景:事后获取实际消耗(用于计费或监控)和事前预估(用于控制上下文窗口)。
事后统计
每次 LLM 调用完成后,LangChain4j 的 Response 或 ChatResponse 对象中会携带一个 TokenUsage 实例。这是最准确的数据,因为它是模型服务商(如 OpenAI)在响应中直接返回的官方统计。
TokenUsage 类提供了三个核心方法:
- inputTokenCount():输入 Token 数(你的 prompt + 历史消息 + 工具描述等)
- outputTokenCount():输出 Token 数(模型生成的回复)
- totalTokenCount():总消耗
在 ReAct 循环中,由于工具调用会触发多次 LLM 调用,你需要把每一次调用的 TokenUsage 累加起来。TokenUsage 类自带 add() 方法,可以方便地合并两次调用的统计。
事前预估
在LangChain4j内部有一个类叫做:dev.langchain4j.model.openai.OpenAiTokenCountEstimator如下图代码截图:
OpenAiTokenCountEstimator 底层调用的是 JTokKit 库,它实现了 OpenAI 的 BPE(Byte Pair Encoding)分词逻辑。它会根据你指定的模型名称,选用对应的编码表(例如 GPT-4 系列用 O200K_BASE),把文本真正“切”成 token 序列后再计数。下边截图的源代码有应证:
JTokkit 实现的是 OpenAI 的字节级 BPE(Byte-level BPE)算法,它把任何文本(无论中英数)都先转换为 UTF-8 字节序列,然后基于预训练的合并规则进行切分。
因此,中文汉字、英文单词、数字在它眼里没有本质区别——都是字节流。它们最终被切成几个 token,完全取决于该 token 在训练语料中的出现频率和合并规则,而不是代码里写了“汉字按 X 处理、数字按 Y 处理”。它的使用方式也很简单,如下:
import dev.langchain4j.model.openai.OpenAiTokenCountEstimator; public static void main(String[] args) { OpenAiTokenCountEstimator estimator = new OpenAiTokenCountEstimator("gpt-4o-mini"); int tokenCount = estimator.estimateTokenCountInText("你好,世界!"); System.out.println(tokenCount);}那么看到这里,你可能会有疑惑,我知道如何直接用这个Api了,但是在实际应用场景中,大多都是定义一个Assisant的类去访问的LLM,那么如何在这个访问过程中去控制Token消耗呢?这里就需要引出我们接下来要将的上下文工具了。
对话上下文中控制Token上限
通过 AiServices.builder() 定义 Assistant 时,OpenAiTokenCountEstimator本身不会被自动“注入”到 LLM 调用流程里。它的典型用武之地是配合 TokenWindowChatMemory,让 Assistant 在管理对话记忆时,用这个预估器来精确控制上下文的 Token 预算。
所以实际使用Token限制器需要注入进我们的Assistant对象内部,例如下边代码案例所示:
@Bean@ConditionalOnBean({StreamingChatModel.class, IAgentToolsService.class, SkillLoader.class,IAgentConfigService.class})public IGreenNoteAssistant greenNoteAssistant(StreamingChatModel streamingChatModel, IAgentToolsService agentToolsService, SkillLoader skillLoader) { return AiServices.builder(IGreenNoteAssistant.class) .streamingChatModel(streamingChatModel) .maxSequentialToolsInvocations(3) .chatMemoryProvider(memoryId-> TokenWindowChatMemory.builder() .maxTokens(100 * 10000, new CustomTokenCountEstimator()) .build()) .tools(agentToolsService) .systemMessageProvider(memoryId -> { return agentProperties.getDefaultSystemPrompt() + BASE_CONTENT + skillLoader.getSkillBody(); }) .build();}LangChain4j 的对话上下文管理,核心是 ChatMemory 抽象。它解决一个根本问题:LLM 本身是无状态的,必须由外部把历史消息带在每次请求里。ChatMemory 就是这个“历史消息容器”,并附加了淘汰策略、多会话隔离和持久化能力。
对话上下文内部设计
ChatMemory 是对话历史的容器,主要职责是存储和检索 ChatMessage 列表。它有两个开箱即用的实现,区别在于按什么维度淘汰旧消息。
MessageWindowChatMemory
滑动窗口按消息条数保留。比如设 maxMessages(10),就只保留最近 10 条,更早的被淘汰。实现简单,但每条消息的 Token 数差异巨大,一条长工具结果可能就占满窗口,所以官方定位是“快速原型用”。
TokenWindowChatMemory
滑动窗口按 Token 数保留。它需要一个 TokenCountEstimator 来估算每条消息的 Token 数,超出预算就淘汰最旧的。这正是你之前用 OpenAiTokenCountEstimator 配合的那个组件。消息是不可分割的,如果一条消息放不进窗口,整条被淘汰,不会截断。
两者对 SystemMessage 都有特殊保护:一旦加入,始终保留,不会被淘汰。一般来说,在建设Agent的时候,我们都是将用户id粒度作为对话的记忆区分粒度,实现起来就和下边这段代码相似:
.chatMemoryProvider(memoryId -> TokenWindowChatMemory.withMaxTokens(4000, estimator))海量对话记录存储
这里我会结合自己过往的工作经验,和大家分享下自己过去负责的Agent基座平台的一些设计思路启发。一般大型企业内部都会有一套自建的Agent基座平台,这类平台会涵盖了整个业务线/集团内部Agent的部署和使用。这也就意味着,可能该平台上会有来自不同团队成百上千个Agent智能体的部署和使用。
面对数以百/千计的Agent智能体,每个智能体的对话记录都需要考虑消息存储问题,这种情况下笔者过往是采用了MySQL+分表的策略进行的持久化,同时按照日期(30/60/90天)冷备到离线进行归档。
对话记录表SQL:
CREATE TABLE `t_agent_chat_record_{0..127}` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `chat_id` bigint unsigned NOT NULL COMMENT '消息ID(雪花,全局唯一)', `relation_id` bigint unsigned NOT NULL COMMENT '会话ID(分片键)', `agent_id` bigint unsigned NOT NULL DEFAULT '0' COMMENT 'agent标识/区分不同agent智能体', `user_id` bigint unsigned NOT NULL DEFAULT '0' COMMENT '用户id', `role` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '1system 2user 3assistant 4tool', `type` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '内容类型', `content_text` text NOT NULL DEFAULT '' COMMENT '回复的纯文本内容', `content_full` varchar(256) NOT NULL DEFAULT '' COMMENT '完整内容的地址指针', `seq_no` int unsigned NOT NULL DEFAULT '0' COMMENT '会话内序号', `token_count` int unsigned NOT NULL DEFAULT '0', `status` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '0生成中1成功2失败3中断', `finish_reason` varchar(32) NOT NULL DEFAULT '', `parent_id` bigint unsigned NOT NULL DEFAULT '0' COMMENT '重生成父消息', `is_deleted` tinyint(1) NOT NULL DEFAULT '0', `deleted_at` datetime DEFAULT NULL, `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`, `create_time`), UNIQUE KEY `uk_chat_id` (`chat_id`, `create_time`), KEY `idx_relation_seq` (`relation_id`, `seq_no`), KEY `idx_relation_time` (`relation_id`, `create_time`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里由于考虑到agent的回复sse事件会非常巨大,所以建议单独一份表去存储实际的sse内容,详情表设计可以参考如下:
CREATE TABLE t_agent_chat_detail_{0..127} ( chat_id bigint unsigned NOT NULL COMMENT '消息ID,与主表chat_id一致', relation_id bigint unsigned NOT NULL DEFAULT '0' COMMENT '冗余,便于按会话归档', content longtext COMMENT '完整内容(可zstd压缩后存)', ext varchar(2048) NOT NULL DEFAULT '{}' COMMENT '扩展信息子弹', created_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (chat_id, created_time)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;在t_agent_chat_record表里面只保留sse内部agent回复的文本信息,其他的sse事件单独一份表存储。sse事件的详情表也可以做冷热区分,部分定期归档到离线库中。
会话关系表:
CREATE TABLE `t_agent_chat_relation_{0..127}` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `relation_id` bigint unsigned NOT NULL COMMENT '会话ID(雪花)', `conversation_uuid` char(36) NOT NULL DEFAULT '', `tenant_id` bigint unsigned NOT NULL DEFAULT '0', `agent_id` bigint unsigned NOT NULL DEFAULT '0' COMMENT '对应Agent', `user_id` bigint unsigned NOT NULL DEFAULT '0', `last_msg_preview` varchar(512) NOT NULL DEFAULT '', `last_msg_seq` bigint unsigned NOT NULL DEFAULT '0', `msg_count` int unsigned NOT NULL DEFAULT '0', `total_tokens` bigint unsigned NOT NULL DEFAULT '0', `type` tinyint unsigned NOT NULL DEFAULT '0', `status` tinyint unsigned NOT NULL DEFAULT '0', `latest_msg_time` datetime DEFAULT NULL, `metadata` json DEFAULT NULL, `is_deleted` tinyint(1) NOT NULL DEFAULT '0', `deleted_at` datetime DEFAULT NULL, `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_relation_id` (`relation_id`), UNIQUE KEY `uk_conversation_uuid` (`conversation_uuid`), KEY `idx_user_latest` (`user_id`, `is_deleted`, `latest_msg_time` DESC), KEY `idx_tenant_agent_latest` (`tenant_id`, `agent_id`, `latest_msg_time` DESC)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;关于MySQL分表存储这块,具体查询的路由策略如下:
- 消息表
t_agent_chat_record分 128 张:分片键relation_id,路由shard = relation_id % 128 - 大字段表
t_agent_chat_detail分 128 张:同分片键relation_id,同路由(保证relation_id落在同一分片,跨表同库) - 关系表
t_agent_chat_relation:数据量小,不分表(或按user_id % 128)
这里仔细看的同学可能会有疑惑,为什么t_agent_chat_detail表也要按照relation_id分片,而不是按照chat_id分片呢?
这里我举个例子,首先我们来看按照chat_id路由分片的思路:
假设有一个用户A,需要查看自己的某个会话历史记录,那么查询将会是:
- 查询用户A所拥有的会话记录id
select * from t_agent_chat_relation_{A.user_id%128}where user_id=? order by latest_msg_time desc limit ?,?;- 查询对应会话记录下的对话内容-步骤1
select * from t_agent_chat_record_{conversation_id%128}where conversation_id=? order by id desc limit ?,?;- 最后在内存中按照chatId再到chat_detail表并发捞详情数据,这里假设上一步骤查询出来的chat_id有50个,极端情况下需要路由到50张表下去查询。
select * from t_agent_chat_detail_1where chat_id in (?,?);...select * from t_agent_chat_detail_50where chat_id in (?,?);即使在应用层用了多线程并发,内存加大,高并发下,这种设计还是很容易出现OOM问题。
好了,现在我们回过头来看,假设一开始我们就按照relation_id去对消息详情表做路由的话,那么最后的查询就会只有一次(因为一般产品交互都是先点开会话,在查看会话内的对话记录):
select * from t_agent_chat_detail_1where relation_id =?;这样的设计可以很大程度上减少对MySQL的IO访问次数。
但是可能也有人会疑惑,如果我要按照chatId进行详情查询呢?
问题不大,用基因法去对消息路由进行优化即可。生成chat_id时把relation_id % 128编进高位,查时解出来。
例如下边这段代码:
// chat_id 64 位:高 7 位 shard(128 分片)+ 低 57 位雪花序列int shard = (int)(relationId % 128); // 0~127long chatId = ((long) shard << 57) | snowflakeSeq;查询的时候如下:
int shard = (int)(chatId >>> 57); // 解出 shardString table = "t_agent_chat_detail_" + shard;SELECT content FROM table WHERE chat_id = ?;小结
好了,写到这里,文章大概也就先告一段落了,最近工作比较繁忙,后续更新速度也基本是一周一次。
这次重点和大家分享了下我在基于 ReAct 范式的实践领域的一些调优,存储,踩过的坑,希望能对大家有所帮助。
大模型的知识又多又杂,到底怎么学?
做这行久了,被问最多的问题就一个:大模型的知识又多又杂,我到底先学啥?翻了一堆资料,看啥都像重点,结果啥都没学进去。今天把我自己一路摸出来的顺序捋一遍,零基础、应届生、转行的都适用。跟着走,至少方向不乱、不瞎忙,学完拿得出手。
第一阶段(10天):初阶应用
这一阶段就一个目标:把大模型真正"玩明白",不是只会聊天那种,而是能调教它、能用代码把大模型和业务接起来。
- 大模型 AI 能干什么?
- 大模型是怎样获得「智能」的?
- 用好 AI 的核心心法
- 大模型应用业务架构
- 大模型应用技术架构
- 代码示例:向 GPT-3.5 灌入新知识
- 提示工程的意义和核心思想
- Prompt 典型构成
- 指令调优方法论
- 思维链和思维树
- Prompt 攻击和防范
- …
第二阶段(30天):高阶应用
这阶段正式进实战,目标很实在:做出两个能拿出手的东西——一个私有知识库,一个 agent 对话机器人。
为什么要做 RAG
搭建一个简单的 ChatPDF
检索的基础概念
什么是向量表示(Embeddings)
向量数据库与向量检索
基于向量检索的 RAG
搭建 RAG 系统的扩展知识
混合检索与 RAG-Fusion 简介
向量模型本地部署
…
走完这 30 天,Python 或 JavaScript 背景的基本能独立做一套 RAG 应用,简历上也敢写"大模型应用开发"了。
第三阶段(约20天):动手微调
学到这,你已经开始 “造” 模型了 —— 通过微调,搞出垂直领域的专用小模型。
- 为什么要做 RAG
- 什么是模型
- 什么是模型训练
- 求解器 & 损失函数简介
- 小实验2:手写一个简单的神经网络并训练它
- 什么是训练/预训练/微调/轻量化微调
- Transformer结构简介
- 轻量化微调
- 实验数据集的构建
- …
恭喜你,如果学到这里,你基本可以找到一份大模型 AI相关的工作,自己也能训练 GPT 了!通过微调,训练自己的垂直大模型,能独立训练开源多模态大模型,掌握更多技术方案。
到此为止,大概2个月的时间。你从 “只会用” 变成 “能造” 的 AI 小子。那么你还想往下探索吗?
想要入局AI大模型赛道、抢占行业红利的朋友,微信扫描下方CSDN官方认证二维码,即可100%免费领取全套学习资料!
第四阶段(20天):商业闭环,部署落地
技术学完,落地才算数。这阶段带你搞明白大模型怎么真正跑起来、用起来。
- 硬件选型
- 带你了解全球大模型
- 使用国产大模型服务
- 搭建 OpenAI 代理
- 热身:基于阿里云 PAI 部署 Stable Diffusion
- 在本地计算机运行大模型
- 大模型的私有化部署
- 基于 vLLM 部署大模型
- 案例:如何优雅地在阿里云私有部署开源大模型
- 部署一套开源 LLM 项目
- 内容安全
- 互联网信息服务算法备案
- …
想要入坑大模型的朋友,给大家准备了一份2026 年最新 AI 大模型全套学习资料:
1、大模型系统化学习路线
2、大模型学习视频教程
3、大模型学习书籍&电子文档
4、AI大模型最新行业报告
5、大模型项目实战&配套源码
6、2026大模型大厂面试真题
7、为什么要学习大模型?
我国在A大模型领域面临人才短缺,数量与质量均落后于发达国家。2023年,人才缺口已超百万,凸显培养不足。随着AI技术飞速发展,预计到2025年,这一缺口将急剧扩大至400万,严重制约我国AI产业的创新步伐。加强人才培养,优化教育体系,国际合作并进是破解困局、推动AI发展的关键。
8、适合谁学?
- 传统后端 / Java / 前端开发,想转型 AI 应用
- 大学生、应届生,想拿更好的 offer
- 产品经理、运营,想武装职业竞争力
- 技术负责人,想给团队落地提效
学习是反人性的,但回报是真金白银。技术会更新,赛道会切换,但只要你先动手,机会就永远站在你这边。
9、这些资料真的有用吗?
这份资料由我和鲁为民博士(北京清华大学学士和美国加州理工学院博士)共同整理,现任上海殷泊信息科技CEO,其创立的MoPaaS云平台获Forrester全球’强劲表现者’认证,服务航天科工、国家电网等1000+企业,以第一作者在IEEE Transactions发表论文50+篇,获NASA JPL火星探测系统强化学习专利等35项中美专利。本套AI大模型课程由清华大学-加州理工双料博士、吴文俊人工智能奖得主鲁为民教授领衔研发。
资料内容涵盖了从入门到进阶的各类视频教程和实战项目,无论你是小白还是有些技术基础的技术人员,这份资料都绝对能帮助你提升薪资待遇,转行大模型岗位。
想要入局AI大模型赛道、抢占行业红利的朋友,微信扫描下方CSDN官方认证二维码,即可100%免费领取全套学习资料!
好了,就写到这。路线是死的,人是活的,卡住了随时来问我。觉得有用就点个收藏,别让它进收藏夹吃灰——动起来比啥都强。