
说到Agent开发我自己走过的路是这样的先从框架入手LangChain、Dify、CrewAI挨个试跑通了几个demo之后就发现真正卡住进度的根本不是Prompt怎么写、工具怎么调而是“模型”这一层。市面上主流Agent框架默认适配的是各家云端API但实际项目里我们经常要接私有化部署的模型、微调过的底座、甚至团队自研的推理服务。这时候就绕不开一个活自定义模型封装。这篇是Agent实践系列的第二篇就专门聊清楚这件事——为什么模型需要封装、封装到底在封什么、怎么把一个本地模型接到LangChain里跑Agent以及我在并发和流式上踩过的坑。内容偏实操适合已经在用Agent框架、但想脱离“只能调云API”限制的朋友。1. 先想清楚为什么需要把模型“封装”起来1.1 Agent与模型之间的那道“接口鸿沟”Agent框架本质上是一个“调度系统”它负责决定下一步调哪个工具、什么时候该把结果交给模型、怎么把模型输出解析成结构化指令。但框架本身不认识任何一个具体的模型它只认一套约定好的“模型接口”。这就好比你家电器都靠国标插座供电但发电厂发出来的电是高压电中间必须有变压器和插座标准来转换。模型封装干的就是“变压器插座”的活把千奇百怪的模型推理服务统一转换成Agent框架能直接插的接口形态。很多人一开始不理解这一点觉得“模型不是我直接在代码里调一下就行吗”其实在Agent场景里完全不是一回事。Agent会来回多次调用模型先规划再调用工具再把工具结果回填给模型做下一步决策。这个循环里每次调用都要经过框架的封装层所以模型接口是否规范直接决定了Agent跑得稳不稳。1.2 不封装直接硬接会遇到哪些坑我在最初做Agent原型时犯过一个错误直接在自定义工具函数里用requests.post()调私有模型接口把返回结果当作字符串塞回去。表面上看流程能走通但问题很快暴露出来参数不统一我们用的本地模型是Qwen系列temperature叫temperature没问题但有些模型服务是top_p、repetition_penalty这类参数框架全部传同一个dict模型直接报错。流式输出断裂框架开启streamTrue之后模型返回的是一个个chunk我的硬编码调用根本没法处理流式事件等于是把流式功能废了。Token用量无法统计Agent每轮对话消耗多少Token框架是有记录的但直接硬接时Token计数是0成本核算和上下文管理直接失效。超时和重试逻辑缺失框架默认会对云API做超时重试但私有化模型推理速度波动大我硬接的那段代码一旦推理超时就整体卡死。这些坑叠加在一起最直接的后果就是Agent在简单场景下能跑但一进入多轮工具调用就开始抽风。所以“封装”不是一个可选项而是把模型接进Agent体系前的必经步骤。2. 封装到底封的是什么核心设计拆解2.1 把模型调用抽象成“三件事”不管模型底层是Transformer、Diffusion还是别的架构Agent框架关心的事情其实只有三件第一件事把输入参数标准化。框架传过来的参数通常有model、messages、temperature、max_tokens、stream等封装层需要把这套通用参数翻译成目标模型能理解的格式。很多自研模型服务参数命名很随意封装层就是那道“翻译官”。第二件事把输出结果标准化。模型返回的可能是纯文本、JSON、带特殊标记的结构化内容封装层要解析成框架能识别的AIMessage对象并把Token用量、Finish Reason这些元数据一并提取出来。第三件事把错误标准化。模型服务报错时错误码五花八门封装层需要把超时、限流、参数非法、模型不存在等情况统一映射成框架能处理的异常类型这样Agent才能决定是重试还是换策略。我做过一个比喻封装层就像一个“万能转接头”任何模型塞进来都变成USB-C口框架插什么设备都通电。2.2 OpenAI兼容协议最省力的封装方式在讲具体实现之前我想先推荐一个“曲线救国”的方案——OpenAI兼容协议。这不是什么新概念但现在行业里已经形成事实标准了绝大多数Agent框架LangChain、Dify、CrewAI、FastGPT等都把OpenAI的接口格式作为默认接入方式。所以如果你把私有模型包装成一个OpenAI兼容的服务端就能直接用框架里现成的OpenAI客户端接入一行代码不用改。为什么这招有效因为OpenAI的/v1/chat/completions接口设计得足够简洁完整请求体里有model、messages、temperature、max_tokens、stream这些通用字段响应体里有choices、usage、finish_reason这些标准结构。只要你的封装层把私有模型的输入输出映射成这套格式框架侧就是零成本接入。而且这个方案的最大好处是一次封装到处复用。不管是LangChain、Dify还是自己写的调度脚本只要支持OpenAI协议都能直接连这个服务根本不需要针对每个框架单独写适配器。2.3 直接实现框架的LLM基类以LangChain为例当然OpenAI兼容协议不是万能的。有些场景下你需要更精细的控制或者你的Agent框架不走OpenAI协议这套路子这时候就要直接去实现框架的LLM基类。以LangChain为例它的BaseLLM接口要求子类实现_generate方法非流式入口和_stream方法流式入口还需要定义_llm_type属性作为模型标识。这个路线的优势是能深度融入框架的Pipeline比如自动处理Prompt模板、输出解析器、回调事件等缺点是工作量大而且要跟着框架版本升级迭代。我自己的实践经验是如果你用的是LangChain这类框架直接实现基类更“正宗”因为后续要用到ChatPromptTemplate、OutputParser这些高阶功能时基类封装能让所有环节无缝衔接。而如果你用的框架本身就是个“大杂烩”或者你有多个框架要接入同一个模型那OpenAI兼容协议更划算。3. 一步步实操把一个本地模型封装成Agent可用的服务3.1 准备工作与环境说明这部分我以“把一个本地部署的Qwen模型封装成OpenAI兼容服务并接入LangChain Agent”为例给你一套可以直接照着跑的最小实现。先说环境准备。建议环境 - Python 3.10 - FastAPI uvicorn负责起HTTP服务 - openai Python SDK用于客户端联调测试 - langchain-openai用于LangChain接入 - 一个本地模型推理服务例如vLLM、Ollama、或自研的transformers推理服务我这次用的推理服务是vLLM启动的Qwen2.5-7B监听在127.0.0.1:8001。vLLM本身已经提供了OpenAI兼容接口按道理可以直接用但为了演示“自定义模型封装”的过程我故意在中间加一层自己的FastAPI服务模拟“模型服务接口不规范需要转接”的场景。提示如果你用的模型服务本身已经是OpenAI兼容的比如vLLM、Ollama的openai模式那你要做的封装就非常简单——更像是一层“网关”而不是“转换器”。但思路是一样的。3.2 实现OpenAI兼容接口的核心代码下面这段代码是封装的核心我尽量把注释写得详细方便你理解每个字段的用意。# app.py from fastapi import FastAPI, Request from fastapi.responses import StreamingResponse, JSONResponse import httpx import json import asyncio # 本地模型推理服务地址 UPSTREAM_URL http://127.0.0.1:8001/v1/chat/completions app FastAPI() # 请求体结构直接用 Pydantic 定义 from pydantic import BaseModel from typing import List, Dict, Optional, Union class ChatMessage(BaseModel): role: str content: str class ChatRequest(BaseModel): model: str qwen2.5-7b-instruct messages: List[ChatMessage] temperature: Optional[float] 0.7 max_tokens: Optional[int] 2048 stream: Optional[bool] False top_p: Optional[float] 1.0 app.post(/v1/chat/completions) async def chat_completions(req: ChatRequest): # 构建转发给上游模型服务的请求 payload { model: req.model, messages: [m.dict() for m in req.messages], temperature: req.temperature, max_tokens: req.max_tokens, stream: req.stream, } if req.top_p is not None: payload[top_p] req.top_p # 非流式处理 if not req.stream: async with httpx.AsyncClient(timeout120) as client: resp await client.post(UPSTREAM_URL, jsonpayload) resp.raise_for_status() data resp.json() # 把上游响应转换成 OpenAI 格式 openai_response { id: chatcmpl-custom- data.get(id, ), object: chat.completion, created: int(time.time()), model: req.model, choices: [ { index: 0, message: { role: assistant, content: data[choices][0][message][content], }, finish_reason: data[choices][0].get(finish_reason, stop), } ], usage: { prompt_tokens: data.get(usage, {}).get(prompt_tokens, 0), completion_tokens: data.get(usage, {}).get(completion_tokens, 0), total_tokens: data.get(usage, {}).get(total_tokens, 0), }, } return JSONResponse(contentopenai_response) # 流式处理使用 SSE 格式返回 async def generate_stream(): async with httpx.AsyncClient(timeoutNone) as client: async with client.stream(POST, UPSTREAM_URL, jsonpayload) as resp: async for line in resp.aiter_lines(): if not line: continue if line.startswith(data: ): chunk line[6:] if chunk [DONE]: # 最后补一个包含 usage 的结束 chunk final_usage { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0, } usage_chunk { id: chatcmpl-custom, object: chat.completion.chunk, created: int(time.time()), model: req.model, choices: [], usage: final_usage, } yield fdata: {json.dumps(usage_chunk)}\n\n yield data: [DONE]\n\n break # 直接透传上游的 chunk yield fdata: {chunk}\n\n return StreamingResponse(generate_stream(), media_typetext/event-stream)这段代码里有几个细节要特别注意第一超时时间要设够。本地模型在没有GPU加速的极端情况下一个长上下文请求可能跑几十秒我在非流式场景里把超时设成了120秒流式场景直接设为None避免中途断流。第二流式结束时要补齐usage。很多Agent框架尤其是LangChain在解析流式响应时会等一个包含usage的最终chunk来记录Token消耗。如果上游不返回这个你的封装层就在[DONE]之前自己构造一个补上。我吃过亏一开始没补LangChain的OpenAI回调里Token统计全是0。第三最好不要只透传上游字段。有些上游返回的消息结构里夹杂着额外字段比如num_tokens、output_text直接透传给Agent框架可能会触发解析错误。所以我在非流式分支里用message.content这个标准字段重新组装了响应。3.3 封装后的联调测试服务写完之后先用uvicorn跑起来uvicorn app:app --host 0.0.0.0 --port 8002然后用curl验证非流式接口curl http://127.0.0.1:8002/v1/chat/completions \ -H Content-Type: application/json \ -d { model: qwen2.5-7b-instruct, messages: [ {role: user, content: 你好请介绍一下你自己} ], stream: false }正常时你会收到一个OpenAI格式的JSON响应里面包含choices[0].message.content和usage字段。接着验证流式接口curl -N http://127.0.0.1:8002/v1/chat/completions \ -H Content-Type: application/json \ -d { model: qwen2.5-7b-instruct, messages: [ {role: user, content: 给我讲个笑话} ], stream: true }你会看到一行行data: {...}刷出来最后以data: [DONE]结束。这就是标准的SSE流LangChain和OpenAI SDK都能直接解析。再用Python的OpenAI SDK测一遍顺便验证“客户端一行代码不改”的效果from openai import OpenAI client OpenAI( base_urlhttp://127.0.0.1:8002/v1, api_keysk-no-need, # 本地封装不需要真实key占位即可 ) resp client.chat.completions.create( modelqwen2.5-7b-instruct, messages[{role: user, content: 你好}], streamTrue, ) for chunk in resp: if chunk.choices: print(chunk.choices[0].delta.content, end)3.4 LangChain接入一条代码切换回OpenAI联调通过后接入LangChain就非常简单了。用langchain-openai的ChatOpenAI把base_url指到你的封装服务from langchain_openai import ChatOpenAI from langchain.agents import create_tool_calling_agent, AgentExecutor from langchain_core.prompts import ChatPromptTemplate llm ChatOpenAI( modelqwen2.5-7b-instruct, base_urlhttp://127.0.0.1:8002/v1, api_keysk-no-need, temperature0.7, streamingTrue, ) prompt ChatPromptTemplate.from_messages([ (system, 你是一个有帮助的助手尽量使用工具回答问题。), (human, {input}), (placeholder, {agent_scratchpad}), ]) # 这里假设你定义了自己的工具函数tool 列表里放着工具 agent create_tool_calling_agent(llm, tools, prompt) executor AgentExecutor(agentagent, toolstools, verboseTrue) result executor.invoke({input: 帮我计算一下24乘以37等于多少}) print(result)这段代码最妙的地方在于你只是换了base_url和model两个参数其余全部复用OpenAI那套逻辑。如果哪天你又切回OpenAI官方API只要把base_url恢复默认值就行Agent逻辑完全不用动。这种“无感切换”能力正是封装带来的最大价值。注意用create_tool_calling_agent时你的模型必须支持工具调用function calling / tool calling。Qwen2.5系列的指令微调版本支持这个但如果你用的是纯基座模型需要换成普通的create_react_agent或者专门用支持工具调用的模型服务。4. 封装中最容易翻车的三个地方并发、流式与错误处理4.1 并发问题本地模型服务根本扛不住热搜词里有个特别扎眼的问题“ai agent怎么扛并发”。这个在自封装模型服务里尤其突出因为本地模型的并发能力是硬瓶颈。你封装得再漂亮上游推理服务一打满请求就开始排队然后客户端超时报错Agent直接失败。我第一次把封装服务放到测试环境结果10个用户同时发起Agent对话服务直接雪崩。排查下来原因很典型FastAPI是异步的async def接口天然可以并发接收请求但我的上游vLLM实例只有一块GPU推理任务是串行的。请求堆到vLLM队列里单个推理就要几十秒客户端早就等不下去了。解决办法有两个层面第一个层面在封装层做排队和并发限制。我在FastAPI接口外面套了一个asyncio.Semaphore同一时间只放行N个请求到上游N根据你的GPU显存和模型大小调比如7B模型单卡420GB显存大概能并行2-4个推理。from asyncio import Semaphore # 全局信号量控制同时打到上游的请求数量 sem Semaphore(2) app.post(/v1/chat/completions) async def chat_completions(req: ChatRequest): async with sem: # 原有转发逻辑 ...第二个层面在Agent客户端侧配置合理的超时重试。LangChain的ChatOpenAI支持request_timeout参数我建议设成比你的模型预期最大推理时间再长10-20秒。重试策略也不要无脑重试最好用指数退避第一次失败等2秒第二次等4秒第三次等8秒最多重试3次。这样既能扛住瞬时并发峰值又不会因为模型服务“假死”而无限等待。4.2 流式输出SSE格式里的“隐形炸弹”流式输出是Agent体验的关键因为用户等待模型答复时最怕“静默”。但流式封装也是最容易出细碎问题的地方。第一个坑SSE的格式必须严格。每个数据块必须以data:开头每个块之间用\n\n分隔。有次我在代码里偷懒写成了yield json.dumps(chunk)结果OpenAI SDK直接解析失败报了一个莫名其妙的NameError。排查了半天才发现是流式格式少了data:前缀。第二个坑代理服务器会缓冲流。这个问题比较隐蔽。如果你的封装服务前面还有一层Nginx或网关默认会开启缓冲导致客户端迟迟收不到首字节。需要给Nginx配置proxy_buffering off;或者给FastAPI配上X-Accel-Buffering: no响应头才能保证流式真的“流”起来。第三个坑流式中途遇到模型生成异常。比如生成到一半模型报了个length错误框架需要能感知到这个异常。规范做法是在流式中吐出一个带error字段的特殊块或者直接断开SSE连接。我踩过的坑是直接raise异常导致连接非正常断开客户端那边收到的是半截流后来改成了先吐一个标准错误块再关闭连接LangChain才能正确捕获并重试。4.3 错误处理到底哪些错误该重试Agent框架的重试机制是把双刃剑。设计封装层的时候你必须明确告诉框架哪些错误你可以放心重试哪些错误重试一万次都没用。我的经验是分三类可重试上游返回5xx错误、超时、连接被重置。这类错误通常是瞬时资源问题等几秒重试大概率能成功。不可重试上游返回400错误、参数格式非法、消息内容包含不合法字符。这类错误是代码Bug或输入问题重试只会浪费算力。有条件重试上游返回429限流。这时候要看响应头里的Retry-After字段按它指定的时间等待后重试而不是自己拍脑袋。如果对方没给这个字段用指数退避保守一点。在LangChain里你可以通过max_retries控制重试次数并通过自定义Retry回调来区分错误类型。我的习惯是默认允许重试但在异常信息里带上retryable标记这样框架就能快速判断。5. 我的避坑清单与几点经验5.1 一个表格说清常见问题与对策现象根因对策非流式请求偶尔卡死超时时间设置过短httpx.Timeout(120)或按最大推理时长动态调整流式输出时断时续SSE格式不规范或缺\n\n严格按data: xxx\n\n格式yieldToken统计为零流式响应缺usage终结块在[DONE]前补一个带usage的chunkAgent工具调用失败模型不支持function calling换指令微调模型或改用ReAct Agent并发一高就雪崩上游推理服务串行封装层加信号量限流调度到多个推理实例响应里出现“嗯嗯啊啊”模型被解析成父子角色chunk合并增量内容或检查delta.content是否有累积逻辑5.2 几个让我少走弯路的实操心得第一个心得封装层尽量“透传为主改写为辅”。很多字段上游已经给了你只需要做字段重命名和补全千万不要自创格式。我见过有人把整段消息结构重新改成自己设计的key结果下游每个消费方都要跟着改维护成本直接爆炸。第二个心得预留extra字段兜底。模型服务经常会返回一些非常规字段比如自定义的reasoning_content、bio_evidence等如果你在Pydantic模型里没有预留extra字段这些信息会在封装层被丢弃。我的做法是在响应体里加上extra或metadata把上游所有识别不到的字段都塞进去既不影响兼容性又保留了调试线索。第三个心得做一套“回声测试”服务。开发的时候我建议先写一个写死的假模型服务不管请求什么内容都返回“hello world”。这样你可以先把封装层和框架侧的连通性测通再切换真实模型。如果连通性没问题但真实模型表现不清问题一定出在模型侧反过来连通性都没通就先别去调Prompt了。5.3 什么时候别自己封装说了这么多我必须泼一盆冷水自定义模型封装是有成本的不是所有场景都值得做。如果你只是想在个人项目里体验一下Agent的效果直接用框架默认的OpenAI、Claude API就行完全不需要封装。你要封装的是“一个独一无二、只能在内部访问的模型服务”或者“一套多个Agent项目共用的统一模型网关”这时候封装才值得。还有一种情况也别急着封装如果你用的模型服务本身已经提供了OpenAI兼容接口vLLM、Ollama、TGI都有那就直接指base_url最多在中间加一层轻量代理做鉴权和日志不要重复造轮子。真正的“自定义模型封装”更多发生在自研推理服务、老旧的模型服务、或者格式非常特殊的内部框架上。我把这段经验写在文末就是希望你别被“封装”这个词吓到也别被它诱惑。它的本质是一层适配逻辑核心价值是让Agent框架和模型服务解耦。你只要掌握OpenAI协议这个“通用语”再加上对上游模型服务的了解就能写出一个稳定、扛并发、可维护的封装层。后面我会继续更新Agent实践系列聊聊Agent记忆、工具编排、并发架构这些话题。这次先到这里你去试试把自己那个模型接进来跑通一轮Agent对话回来的感受一定比看这篇更深刻。