从零开发 AI Agent(三):接入真实大模型,实现 LLM 调用、Prompt 构建与多轮对话
前言前两个阶段我搭建了 Agent 的基础架构并引入 IntentRecognizer、PathSelector、PathHandlerRegistry 和 ApplicationContainer完成了从“识别用户意图”到“选择执行路径”的分层。第三阶段的目标是让其中的 LLM 路径真正接入大语言模型能够处理普通自然语言对话。我希望通过自己实现模型客户端、统一数据结构、提示词构建和历史消息管理理解一次 LLM 请求究竟是怎样在 Agent 内部流转的而不是只写几行 API 调用就结束。以下示例依据学习记录与现有项目相关模块整理为便于阅读个别代码做了缩减不等于项目全部历史版本的逐行复刻。一、第三阶段的目标与边界定义统一的 LLM 客户端抽象 BaseLLMClient避免业务层绑定具体厂商。先通过 MockLLMClient 验证调用链再接入真实豆包模型。使用 LLMMessage、MessageRole、LLMResult 统一输入与输出。引入 PromptBuilder 组织 system、历史消息和当前用户输入。引入 ConversationHistory 保存不同 user_id 的对话记录。在 LLMPathHandler 中串起请求、模型调用、错误处理与上下文写入。本阶段暂时不展开模型主动选择工具、Tool Calling、Tool Loop以及后续 Workflow 的深度编排。这些属于之后的学习内容。二、架构如何从第二阶段演进用户请求FastAPI↓AgentRuntime↓ IntentRecognizer → PathSelectorPathHandlerRegistry↓ 选择 LLM 路径LLMPathHandler├─ ConversationHistory读取历史├─ PromptBuilder组装消息├─ BaseLLMClient.chatMock / 豆包└─ ConversationHistory写入本轮↓AgentResponse统一响应架构中最值得注意的变化Runtime 不直接拼接 Prompt也不直接依赖豆包 SDK而是让 LLMPathHandler 组织对话流程再通过 BaseLLMClient 调用不同模型。三、为什么需要 BaseLLMClient最初的想法是直接在处理器里创建厂商 SDK 对象并调用接口。但这样一来更换模型时就必须修改处理器测试也依赖真实 API。于是将“模型怎样接入”与“Agent 怎样使用模型”分开。from abc import ABC, abstractmethod from schemas import LLMMessage, LLMResult class BaseLLMClient(ABC): abstractmethod def chat(self, messages: list[LLMMessage]) - LLMResult: pass这里定义了一个统一协议无论使用 Mock 还是豆包调用者只需要知道 chat(messages) 返回 LLMResult。抽象基类的价值不在于增加代码层数而在于限定模块间的依赖方向。四、统一 LLM 的消息和结果 Schemafrom enum import Enum from typing import Any from pydantic import BaseModel class MessageRole(str, Enum): SYSTEM system USER user ASSISTANT assistant TOOL tool class LLMMessage(BaseModel): role: MessageRole content: str class LLMResult(BaseModel): model: str success: bool data: dict[str, Any] | None None usage: dict[str, Any] | None None error: str | None NoneMessageRole 限制消息角色LLMMessage 表示单条消息LLMResult 用于屏蔽模型服务商之间返回数据的差异。这样 Handler 无需直接解析厂商原始响应对象。字段用途model标识实际使用的模型success区分成功与失败data存放模型回答等业务结果usage保存 token 使用量便于后期统计error保存错误信息便于定位问题五、先用 MockLLMClient 打通路径在接入真实模型之前我先通过 Mock 客户端验证“消息传入 → 模型返回 → Handler 响应”这条链路。这样可以把代码逻辑错误和 API 配置错误分开排查。from llm.base_llm_client import BaseLLMClient from schemas import LLMMessage, LLMResult class MockLLMClient(BaseLLMClient): def chat(self, messages: list[LLMMessage]) - LLMResult: last_message messages[-1] return LLMResult( modelmock-llm, successTrue, data{content: f这是 Mock LLM 的回复{last_message.content}}, usage{input_tokens: 0, output_tokens: 0}, errorNone, )Mock 的优势是不用密钥、不消耗 token、速度快、测试结果稳定限制是只能验证程序的调用和数据转换不能验证真实模型的语义能力。六、接入豆包大模型把配置与逻辑分离这一阶段我使用火山引擎的豆包模型。项目以兼容 OpenAI SDK 的接口方式发起请求并从环境变量读取配置。切勿将真实 API Key 写入代码仓库或公开博客。# 依赖安装 pip install openai python-dotenv .env 示例值仅为占位符 LL_PROVIDERdoubao ARK_API_KEY替换为你自己的密钥 ARK_BASE_URL替换为服务商提供的接口地址 ARK_MODEL替换为你的模型接入点标识实际使用时需要按当前服务商的正式文档填写 ARK_BASE_URL 和 ARK_MODEL。不要在文章中展示本地 .env 的真实值。import os from openai import OpenAI from llm.base_llm_client import BaseLLMClient from schemas import LLMMessage, LLMResult class DoubaoLLMClient(BaseLLMClient): def init(self): api_key os.getenv(ARK_API_KEY) base_url os.getenv(ARK_BASE_URL) model os.getenv(ARK_MODEL) if not api_key: raise ValueError(环境变量 ARK_API_KEY 未配置) if not base_url: raise ValueError(环境变量 ARK_BASE_URL 未配置) if not model: raise ValueError(环境变量 ARK_MODEL 未配置) self.client OpenAI(api_keyapi_key, base_urlbase_url) self.model model def chat(self, messages: list[LLMMessage]) -gt; LLMResult: try: payload [ {role: m.role.value, content: m.content} for m in messages ] response self.client.chat.completions.create( modelself.model, messagespayload ) answer response.choices[0].message.content or usage None if response.usage is not None: usage { input_tokens: response.usage.prompt_tokens, output_tokens: response.usage.completion_tokens, total_tokens: response.usage.total_tokens, } return LLMResult(modelself.model, successTrue, data{content: answer}, usageusage) except Exception as exc: return LLMResult(modelself.model, successFalse, dataNone, usageNone, errorstr(exc))/code/pre 这里的重点不是某个具体 API 参数而是把 SDK 响应转换为我们项目统一的 LLMResult。异常不应该在业务层被忽略当前学习版本会在结果中保存 error生产环境还需要日志脱敏、分类错误与超时重试策略。七、真实遇到的问题ARK_API_KEY 未配置初次运行 test_doubao.py 时我遇到了以下错误ValueError: 环境变量 ARK_API_KEY 未配置错误的直接原因是客户端初始化时调用 os.getenv(ARK_API_KEY)但当前 Python 进程并没有读取到该环境变量。这不代表模型 API 不可用而是请求尚未真正发出。我的排查思路逐步明确为先检查 .env 是否存在再确认 python-dotenv 是否正确加载最后确认运行配置的工作目录以及变量名称。from dotenv import load_dotenv load_dotenv() # 应在创建 DoubaoLLMClient 前执行 只检查是否加载成功避免输出密钥 import os print(bool(os.getenv(ARK_API_KEY)))最终通过正确配置并加载环境变量解决。这个错误让我理解了写在 .env 里的内容不会天然成为 os.getenv() 可见的变量必须由操作系统注入或由程序显式加载。八、PromptBuilder把消息拼接逻辑独立出来在没有 PromptBuilder 时很容易在 Handler 中到处拼接 system 文本、历史聊天与当前输入。随着功能变多消息顺序和格式会越来越难维护。from schemas import LLMMessage, MessageRole class PromptBuilder: def build(self, user_message: str, history_messages: list[LLMMessage]) - list[LLMMessage]: messages [ LLMMessage(roleMessageRole.SYSTEM, content你是一个专业的AI助手。) ] messages.extend(history_messages) messages.append( LLMMessage(roleMessageRole.USER, contentuser_message) ) return messages消息顺序是 system → history → 当前 user。本阶段重点是理解“模型并不会自动记住过去的 API 请求”多轮对话需要应用自己保存并重新提供上下文。九、ConversationHistory为什么多轮对话需要记忆组件在接入真实模型之后一个关键问题浮现出来模型本身并不会自动记住过去的 API 请求。每次调用都是独立的多轮对话需要应用自己保存历史消息并在下一次请求时重新提供上下文。这正是 ConversationHistory 存在的意义。from schemas import LLMMessage class ConversationHistory: def __init__(self): self.messages: dict[str, list[LLMMessage]] {} def add_message(self, user_id: str, message: LLMMessage) - None: if user_id not in self.messages: self.messages[user_id] [] self.messages[user_id].append(message) def get_history(self, user_id: str) - list[LLMMessage]: return self.messages.get(user_id, [])这里使用 user_id 隔离不同用户的历史并在进程内用字典临时保存。这对学习和本地测试很直观但有三个限制服务重启会丢失、多个工作进程之间不共享、历史会无限增长。因此它不是持久化记忆系统也不等同于后续阶段的长期 Memory 设计。十、LLMPathHandler把所有模块串起来第二阶段建立的 PathHandler 框架在这里终于拥有真正的 LLM 执行实现。它的流程是读取历史 → 构造消息 → 调用客户端 → 判断成功与否 → 写入本轮 user/assistant 消息 → 返回 AgentResponse。def handle(self, message: str, user_id: str, intent: str): history self.conversation_history.get_history(user_id) messages self.prompt_builder.build(message, history) llm_result self.llm_client.chat(messages) if not llm_result.success: return AgentResponse( messageLLM执行失败, user_iduser_id, intentintent, execution_pathllm, statusfailed, resultllm_result.model_dump(), errorllm_result.error ) answer (llm_result.data or {}).get(content, ) self.conversation_history.add_message( user_id, LLMMessage(roleMessageRole.USER, contentmessage) ) self.conversation_history.add_message( user_id, LLMMessage(roleMessageRole.ASSISTANT, contentstr(answer)) ) return AgentResponse( messageLLM执行成功, user_iduser_id, intentintent, execution_pathllm, statussuccess, resultllm_result.model_dump(), errorNone )上述代码为核心逻辑节选省略了完整类定义和兜底异常处理。值得留意项目采用“请求成功后再写入上下文”的顺序因此失败请求不会作为正常对话写进历史这一选择符合本阶段的简单需求但也需要在未来考虑异常追踪与审计。十一、ApplicationContainer选择 Mock 还是真实模型load_dotenv() provider os.getenv(LL_PROVIDER, mock).lower() if provider doubao: llm_client DoubaoLLMClient() elif provider mock: llm_client MockLLMClient() else: raise ValueError(f不支持的LLM_PROVIDER{provider}) prompt_builder PromptBuilder() conversation_history ConversationHistory() llm_handler LLMPathHandler( llm_clientllm_client, prompt_builderprompt_builder, conversation_historyconversation_history, )这样一来切换模型实现只需要改配置不需要修改 Runtime、PathSelector 或业务 Handler。这是前面学习依赖注入在真实场景中的一次落地。十二、验证和调试怎样确认第三阶段确实完成我将验证拆成四层逐层排查避免所有问题都集中到 FastAPI 接口上。验证层次检查重点Mock 客户端传入 LLMMessage能否返回标准 LLMResultPromptBuildersystem/history/current user 的顺序是否正确ConversationHistory相同 user_id 可以读取历史不同用户相互隔离真实模型 API配置有效、请求成功、响应能通过统一 Schema 返回# 检查基础模块是否能被导入 python -c from llm.doubao_llm_client import DoubaoLLMClient; print(导入成功) 运行单独的模型调用脚本确保环境变量已配置 python test_doubao.py“导入成功”只证明 Python 能找到并加载模块不证明密钥有效、更不证明请求已经成功。这一点是本阶段学习中很重要的排错认识。十三、这一阶段真正理解了哪些设计原则抽象隔离具体实现业务层只依赖 BaseLLMClient不依赖具体模型厂商。先 Mock、后真实服务把程序流程测试与外部 API 问题分开。统一 Schema对接不同模型时稳定内部数据结构。Prompt 与 History 分工一个负责组织消息另一个负责保存消息。单一职责与依赖注入Handler 组织流程Container 负责创建与连接对象。错误是调用链的一部分配置错误、模型请求失败和业务失败要能区分。十四、当前实现的不足与未来改进第三阶段成功建立了可切换 Mock/豆包的 LLM 对话路径但它仍是教学版架构没有持久化历史、上下文裁剪和 token 预算控制没有对模型接口做更完整的重试与超时策略对话模型目前也不会主动决定调用哪一个工具。这些限制不妨碍理解核心原理却明确了后续迭代的方向。十五、第三阶段总结与第四阶段预告回过头看第三阶段最大的收获不是“成功调用了豆包 API”而是建立了一个有明确边界的 LLM 调用层从请求进入 AgentRuntime到 PathSelector 选择 LLM 路径再到 PromptBuilder 组装消息、BaseLLMClient 请求模型以及 ConversationHistory 保存对话整个流程终于连通了。下一阶段我将探索 Tool Calling让大模型不仅能够回答问题还能够判断何时需要工具、生成工具调用参数并让 Agent 执行工具后继续处理结果。这将是从“具有聊天能力”向“具有行动能力”的重要转变。系列文章持续更新从零开发 AI Agent。欢迎一起交流学习中的架构设计、踩坑记录与代码实践。

相关新闻

Serilog 嵌套 Logger 延迟基准:`ForContext` 派生 Logger 的写路径开销实测与源码剖析

Serilog 嵌套 Logger 延迟基准:`ForContext` 派生 Logger 的写路径开销实测与源码剖析

后端可观测性 【免费下载链接】serilog Simple .NET logging with fully-structured events 项目地址: https://gitcode.com/gh_mirrors/se/serilog 点击查看 免费下载 本报告基于 Serilog 仓库 results/net4.5.2/NestedLoggerLatencyBenchmark-report-github.md 中…

2026/10/15 18:27:42 阅读更多 →
RTSP服务器C实现核心原理:从socket到RTP时间戳

RTSP服务器C实现核心原理:从socket到RTP时间戳

简介:这是一份面向嵌入式开发与流媒体服务初学者的RTSP服务器C语言源码解析资源,聚焦RTSP协议实现原理与网络服务编程实践。资源包含414个文件,主体为167个C源码文件(含核心协议解析、RTP/RTCP传输、会话状态机等模块)…

2026/10/15 18:27:42 阅读更多 →
STM32 HAL库I2C轮询读取AS5600磁编码器实战指南

STM32 HAL库I2C轮询读取AS5600磁编码器实战指南

简介:这份资源面向使用STM32 HAL库进行编码器开发的嵌入式工程师与学生,聚焦于通过I2C总线读取AS5600磁编码器角度数据,且采用非DMA的阻塞式读取方式,适合需要理解I2C底层时序、不依赖DMA中断的入门与进阶场景。压缩包共111个文件…

2026/10/15 18:26:40 阅读更多 →

最新新闻

【ESP32笔记】ESP中的分区表

【ESP32笔记】ESP中的分区表

本节目标:看懂分区表 CSV 的每一列,会给工程添加自定义分区,并用 esp_partition API 完成擦除、写入、读取一、本节要解决的问题 ESP32 的程序不是"整个 bin 一股脑烧到某个固定地址",而是把整块 flash 划分成多个分区&…

2026/10/15 19:04:55 阅读更多 →
亲和路由(inspect)

亲和路由(inspect)

Inspect 要解决的是“多轮对话在普通选路下被分到不同渠道”导致缓存失效和兼容性出错的问题;它通过回答“值不值得亲和、按什么粒度分组、用哪些稳定字段”三个问题,输出分类、触发原因和内容哈希;它被设计成无外部状态的纯分析器&#xff0…

2026/10/15 19:04:55 阅读更多 →
AI本体建模-------GB/T 48000.3—2026 标准详解

AI本体建模-------GB/T 48000.3—2026 标准详解

AI本体建模之 GB/T 48000.3—2026 标准详解 当大模型与 Agent 逐渐成为企业基础设施,真正的护城河正从「数据」转向「可被机器理解、推理、校验、共享的知识」。而 本体(Ontology) 正是这套知识体系的「模式层」与「契约层」。 2026 年 1 月&…

2026/10/15 19:04:55 阅读更多 →
线上 Redis 慢查询,不一定就是命令本身慢

线上 Redis 慢查询,不一定就是命令本身慢

上周值班的时候,监控突然弹出 Redis 慢查询告警,慢查询日志里全是 HGETALL,单条耗时经常冲到 80ms 以上。第一反应,又是经典问题,大 Hash 别用 HGETALL,这个知识点群里都说烂了。 一开始我也是这么处理的&a…

2026/10/15 19:04:55 阅读更多 →
有效三角形个数:排序+双指针经典算法详解

有效三角形个数:排序+双指针经典算法详解

做算法题的时候有一种快乐是:题目本身只有一行描述,但背后藏着一整类优化思路。有效三角形个数就是这样——表面上是几何问题,实际上是排序和双指针的经典练兵场,也是我算法题解系列第五篇想重点讲透的题目。题目很直白&#xff1…

2026/10/15 19:04:55 阅读更多 →
SDSegmentedControl避坑指南:iOS 6/7兼容处理、IB集成技巧与现代化改造建议

SDSegmentedControl避坑指南:iOS 6/7兼容处理、IB集成技巧与现代化改造建议

【免费下载链接】SDSegmentedControl A drop-in remplacement for UISegmentedControl that mimic iOS 6 AppStore tab controls 项目地址: https://gitcode.com/gh_mirrors/sd/SDSegmentedControl 点击查看 免费下载 SDSegmentedControl 是一个可以直接替换系统 U…

2026/10/15 19:02:09 阅读更多 →

日新闻

Docker Compose 容器化部署 ChirpStack LoRaWAN 网络服务器实战

Docker Compose 容器化部署 ChirpStack LoRaWAN 网络服务器实战

1. 为什么选择容器化方式部署 LoRa 网络服务LoRa 物联网项目落地时,网络服务器是整个链路的核心枢纽。它负责接收网关上报的数据包、完成去重与解密、再把有效载荷分发给上层应用。早期大家习惯把网络服务器直接装在物理机上,依赖一堆系统库和特定版本的…

2026/10/15 0:00:46 阅读更多 →
共享LLM服务下的跨模型自动扩缩容:Kubernetes实战与避坑指南

共享LLM服务下的跨模型自动扩缩容:Kubernetes实战与避坑指南

1. 共享推理服务里,扩缩容到底难在哪做过大模型推理服务部署的人,大概都经历过这样的场景:一个团队共用一套推理集群,A 业务跑的是 7B 的小模型,B 业务跑的是 72B 的大模型,C 业务白天流量高、晚上几乎归零…

2026/10/15 0:00:46 阅读更多 →
Systemd 启动阶段根文件系统只读之谜:fstab 与 remount-fs 服务深度解析

Systemd 启动阶段根文件系统只读之谜:fstab 与 remount-fs 服务深度解析

1. 引子:一次内核恐慌引发的追查先从一个真实场景说起。某次我给一台老旧的嵌入式设备折腾系统裁减,为了把启动时间压进两秒以内,我手贱地把一串自认为“没用”的 systemd 服务给 disable 了,其中就包括systemd-remount-fs.servic…

2026/10/15 0:00:46 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/15 16:18:54 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/14 16:30:29 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/15 13:48:15 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/15 0:14:36 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/14 22:45:47 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/15 9:49:14 阅读更多 →