从零开发 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。欢迎一起交流学习中的架构设计、踩坑记录与代码实践。