最近团队内部要接大模型能力处理一批文档摘要和对话问答,数据又绕不开内网,我最后选了 Ollama 做本地推理,用 LangChain 做逻辑编排,再封装一层统一的 HTTP API 给业务方调用。整套方案跑下来最大的体会是:Ollama 解决“模型跑得起来”的问题,LangChain 解决“逻辑组织得起来”的问题,而真正在工程上花时间的是 API 封装那部分——参数传递、超时控制、上下文管理、并发处理、流式输出,每一个细节都能让你在线上炸一次。这篇文章把我实际写的代码和踩过的坑完整整理一遍,适合正在做本地大模型服务化的朋友参考,尤其是准备把 Ollama、LangChain 组合起来对外提供接口的开发同学。
1. 项目背景与整体设计思路:为什么是 Ollama 加 LangChain
1.1 需求场景与选型考量
这次项目的诉求很直接:业务方想在自己的系统里接入大模型能力,但核心数据不能出内网。公有云大模型 API 再方便,数据链路这一关就过不了。所以第一步就锁定在私有化部署这套路线上。
模型运行层选型的时候,我对比过 vLLM、llama.cpp、Ollama 三套方案。vLLM 吞吐量确实高,但部署复杂度也高,对显存调度和依赖环境要求比较多,为了一个内部小规模场景去搭一套完整的高性能推理服务,有点杀鸡用牛刀。llama.cpp 灵活,但上层接口和模型管理都要自己动手搓。Ollama 是这三者里最“傻瓜”的,安装完一条命令就能拉模型起服务,自带模型管理、并发调度和 OpenAI 兼容端点,对团队后续维护非常友好。配合 NVIDIA 显卡和 CUDA 环境,qwen2.5、llama3 这类主流开源模型都能直接跑。
编排层选 LangChain 而不是直接裸调 Ollama,核心原因有两个。第一,LangChain 把 ChatMessage、Prompt 模板、输出解析器、RAG 检索这些通用组件都抽象好了,业务方不可能只用一个“烤肉式”的问答接口,很快就会提出“要能参照某些文档回答”“要能按固定格式输出”这类需求,有编排层会从容很多。第二,LangChain 的 Runnable pipeline 天然支持流式、异步和链式组合,后面想加 Agent、加工具调用,改造成本很低。
1.2 三层架构的整体设计
整个项目我拆成了三层,各司其职:
- 接入层:FastAPI 对外提供 RESTful API,负责参数校验、鉴权、路由分发和错误处理。
- 编排服务层:LangChain 封装对话链路、Prompt 模板、历史消息管理、RAG 组合逻辑。
- 模型基础服务层:Ollama 负责模型加载与推理,通过 HTTP 接口暴露能力。
对外统一走 FastAPI,业务方只关心一个接口地址和一套请求格式。内部具体是走 LangChain 链路,还是直接调用 Ollama 原始接口,对调用方完全透明。这样的好处是后面换底层模型、调整推理参数,只要保证接口协议不变,业务方代码一行都不用动。
目录结构我是这么组织的:
code复制llm-api/
├── app/
│ ├── __init__.py
│ ├── main.py # FastAPI 入口
│ ├── api/
│ │ ├── __init__.py
│ │ └── routes.py # 路由层
│ ├── core/
│ │ ├── __init__.py
│ │ ├── config.py # 全局配置
│ │ ├── ollama_client.py # Ollama 原始客户端封装
│ │ ├── langchain_service.py # LangChain 服务封装
│ │ └── service_pool.py # 服务实例缓存池
│ └── schemas/
│ ├── __init__.py
│ └── requests.py # Pydantic 请求/响应模型
├── .env.example
├── requirements.txt
└── README.md
这个结构看起来比单纯一个脚本复杂,但真上了生产环境就发现很值。配置、路由、业务逻辑分开,测试和排错都清晰;服务实例用缓存池管理,避免每个请求都创建新的 LangChain 实例导致的连接开销和资源浪费。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与目录搭建:从 Ollama 安装到 LangChain 依赖
2.1 Ollama 安装与模型拉取
Ollama 的安装在 Linux、macOS、Windows 上都有对应的安装包。我们线上是 Linux 服务器,直接官方脚本装完再用 ollama serve 把服务跑起来,默认监听 11434 端口。装完之后第一件事是确认版本,不同版本的参数行为和并发策略有差异:
bash复制ollama --version
ollama serve
模型拉取我建议按实际需求来,不要一上来就追求大参数。内部业务场景,qwen2.5:7b 这个档位在 8G 显存以上的环境已经能给出不错的效果,中文表现也稳。拉取命令很简单:
bash复制ollama pull qwen2.5:7b
拉完之后用 ollama list 确认本地模型列表,有多个模型的时候,后续 API 请求里可以通过 model 字段动态指定,这也是封装层要暴露的参数之一。
2.2 Python 依赖安装
Python 侧我用的 3.10 版本。核心依赖整理在 requirements.txt 里:
code复制langchain-core>=0.3.0
langchain-ollama>=0.2.0
ollama>=0.4.0
fastapi>=0.115.0
uvicorn[standard]>=0.32.0
pydantic>=2.8.0
pydantic-settings>=2.6.0
这里有个容易踩的坑:LangChain 生态更新很快,不同版本之间 API 差异不小。网上很多教程还在用 from langchain.llms import Ollama,那是老版本写法,新版本已经迁移到 langchain-ollama 这个独立包里,类名也变成了 ChatOllama。如果你照着老教程写,会直接报 ImportError。所以装依赖的时候一定注意版本,尽量统一用我上面列的新系列。
2.3 配置文件与环境变量
配置我单独拆了一个 config.py,用 pydantic-settings 从环境变量和 .env 文件中读取,做到一处修改、全局生效:
python复制# app/core/config.py
from functools import lru_cache
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
ollama_base_url: str = "http://localhost:11434"
ollama_model: str = "qwen2.5:7b"
default_temperature: float = 0.7
default_num_ctx: int = 8192
request_timeout: int = 600
max_history_messages: int = 20
api_token: str = ""
class Config:
env_file = ".env"
@lru_cache
def get_settings() -> Settings:
return Settings()
max_history_messages 这个参数是我后来加的。如果不控制历史消息轮数,对话时间一长,消息列表无限膨胀,迟早把上下文窗口撑爆。后面讲上下文管理的时候还会细说。
.env.example 文件长这样:
code复制OLLAMA_BASE_URL=http://localhost:11434
OLLAMA_MODEL=qwen2.5:7b
DEFAULT_TEMPERATURE=0.7
DEFAULT_NUM_CTX=8192
REQUEST_TIMEOUT=600
API_TOKEN=your-secret-token
2.4 模型目录迁移与资源规划
如果你的 Ollama 装在 Windows 上,模型默认存储在 C 盘用户目录,遇到 ollama 怎么安装到 d 盘、C 盘空间不够这类问题,解决方案是改环境变量 OLLAMA_MODELS,指向 D 盘或其他数据盘,然后重启 Ollama 服务。已经拉下来的模型文件也可以整体移动到新目录。Linux 服务器上也建议把模型路径放在独立数据盘,避免系统盘塞满导致服务异常。
3. 核心代码实现:三层结构的 API 封装完整实操
3.1 Ollama 原始客户端封装
先看最底层,直接封装 Ollama 官方 Python 客户端。这一层解决的是超时、参数统一、错误处理的问题:
python复制# app/core/ollama_client.py
import logging
from typing import Iterator, Optional
import ollama
logger = logging.getLogger(__name__)
class OllamaClient:
def __init__(self, base_url: str, timeout: int = 600):
self.base_url = base_url
self.timeout = timeout
self.client = ollama.Client(host=base_url, timeout=timeout)
def chat(
self,
model: str,
messages: list,
stream: bool = False,
temperature: float = 0.7,
num_ctx: int = 8192,
**kwargs
) -> str | Iterator[str]:
options = {
"temperature": temperature,
"num_ctx": num_ctx,
}
options.update(kwargs)
response = self.client.chat(
model=model,
messages=messages,
stream=stream,
options=options,
)
if stream:
return self._stream_text(response)
return response["message"]["content"]
def generate(
self,
model: str,
prompt: str,
stream: bool = False,
**kwargs
) -> str | Iterator[str]:
response = self.client.generate(
model=model,
prompt=prompt,
stream=stream,
options=kwargs,
)
if stream:
return self._stream_text(response)
return response["response"]
def list_models(self):
return [m.model for m in self.client.list().models]
def model_info(self, model: str):
return self.client.show(model)
@staticmethod
def _stream_text(response: Iterator[dict]) -> Iterator[str]:
for chunk in response:
if chunk.get("done"):
break
yield chunk.get("message", {}).get("content", "")
这里最需要注意的就是超时参数。我当时默认用的是客户端默认超时,结果本地模型处理长文本时动辄几十秒甚至几分钟,直接抛 httpx.ReadTimeout。所以构造 ollama.Client 时一定要显式把 timeout 调大,线上我直接给到了 600 秒。
num_ctx 参数我专门放到了 chat 方法里而不是丢给调用方随意传,因为它是上下文管理最核心的参数。调用方如果乱传一个超出模型上限的值,Ollama 会直接返回 400 错误,这个后面细讲。
3.2 LangChain 服务封装
第二层是 LangChain 封装。这层对外提供会话级能力,包括历史消息转 LangChain 消息格式、Prompt 模板、RAG 扩展等:
python复制# app/core/langchain_service.py
from typing import Optional
from langchain_core.messages import HumanMessage, SystemMessage, AIMessage
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_ollama import ChatOllama
class LangChainService:
def __init__(
self,
model_name: str,
base_url: str = "http://localhost:11434",
temperature: float = 0.7,
num_ctx: int = 8192,
):
self.model_name = model_name
self.llm = ChatOllama(
model=model_name,
base_url=base_url,
temperature=temperature,
num_ctx=num_ctx,
)
self.parser = StrOutputParser()
@staticmethod
def _build_messages(messages: list, system_prompt: Optional[str] = None) -> list:
lc_messages = []
if system_prompt:
lc_messages.append(SystemMessage(content=system_prompt))
for msg in messages:
role = msg.get("role", "user")
content = msg.get("content", "")
if role == "assistant":
lc_messages.append(AIMessage(content=content))
elif role == "system":
lc_messages.append(SystemMessage(content=content))
else:
lc_messages.append(HumanMessage(content=content))
return lc_messages
def chat(self, messages: list, system_prompt: Optional[str] = None) -> str:
lc_messages = self._build_messages(messages, system_prompt)
chain = self.llm | self.parser
return chain.invoke(lc_messages)
def chat_with_template(self, template: str, system_prompt: Optional[str] = None, **kwargs) -> str:
if system_prompt:
prompt = ChatPromptTemplate.from_messages(
[("system", system_prompt), ("human", template)]
)
else:
prompt = ChatPromptTemplate.from_template(template)
chain = prompt | self.llm | self.parser
return chain.invoke(kwargs)
def astream(self, messages: list, system_prompt: Optional[str] = None):
lc_messages = self._build_messages(messages, system_prompt)
return self.llm.astream(lc_messages)
ChatOllama 初始化的时候可以直接传 num_ctx,这个参数最终会体现在 Ollama 的实际推理上下文窗口中。你没有显式设置的时候,Ollama 有默认值,但不同版本、不同模型表现不一致,所以封装层强制显式传递,宁可每次都带上,也不赌默认值。
3.3 Pydantic 请求与响应模型
API 层的请求和响应我用 Pydantic 定义,FastAPI 会自动做参数校验和 OpenAPI 文档生成:
python复制# app/schemas/requests.py
from typing import Optional
from pydantic import BaseModel, Field
class ChatMessage(BaseModel):
role: str = Field(..., description="消息角色: user / assistant / system")
content: str = Field(..., description="消息内容")
class ChatRequest(BaseModel):
model: Optional[str] = Field(None, description="模型名称,不传则用服务端默认值")
messages: list[ChatMessage] = Field(..., description="对话消息列表")
system_prompt: Optional[str] = Field(None, description="系统提示词")
temperature: Optional[float] = Field(0.7, ge=0.0, le=2.0, description="采样温度")
num_ctx: Optional[int] = Field(8192, ge=2048, le=131072, description="上下文窗口长度")
stream: Optional[bool] = Field(False, description="是否流式输出")
class ChatResponse(BaseModel):
code: int = 0
message: str = "success"
data: Optional[str] = None
model: Optional[str] = None
num_ctx 的上下限可以根据部署模型的能力来约束。qwen2.5 系列在 Ollama 上最大支持到 32k 左右,如果上限放得太宽,等于把踩坑的机会全部交给了调用方。默认 8192 对大多数内部业务已经足够。
3.4 FastAPI 路由层
路由层把前面封装的服务串起来,对外提供非流式、流式两个接口,同时做统一鉴权和异常转换:
python复制# app/api/routes.py
import json
import logging
from fastapi import APIRouter, Depends, Header, HTTPException
from fastapi.responses import StreamingResponse
from app.core.config import Settings, get_settings
from app.core.langchain_service import LangChainService
from app.core.service_pool import get_llm_service
from app.core.ollama_client import OllamaClient
from app.schemas.requests import ChatRequest, ChatResponse
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1", tags=["llm"])
def verify_token(
authorization: str = Header(default=""),
settings: Settings = Depends(get_settings),
):
if settings.api_token and authorization != f"Bearer {settings.api_token}":
raise HTTPException(status_code=401, detail="invalid token")
@router.get("/models")
async def list_models(settings: Settings = Depends(get_settings)):
client = OllamaClient(base_url=settings.ollama_base_url, timeout=settings.request_timeout)
return {"models": client.list_models()}
@router.post("/chat", response_model=ChatResponse)
async def chat(
request: ChatRequest,
_: None = Depends(verify_token),
settings: Settings = Depends(get_settings),
):
model = request.model or settings.ollama_model
service = get_llm_service(
model_name=model,
base_url=settings.ollama_base_url,
temperature=request.temperature or settings.default_temperature,
num_ctx=request.num_ctx or settings.default_num_ctx,
)
try:
result = service.chat(
messages=[m.model_dump() for m in request.messages],
system_prompt=request.system_prompt,
)
return ChatResponse(data=result, model=model)
except Exception as e:
logger.exception("chat 接口异常")
raise HTTPException(status_code=502, detail=str(e))
@router.post("/chat/stream")
async def chat_stream(
request: ChatRequest,
_: None = Depends(verify_token),
settings: Settings = Depends(get_settings),
):
model = request.model or settings.ollama_model
service = get_llm_service(
model_name=model,
base_url=settings.ollama_base_url,
temperature=request.temperature or settings.default_temperature,
num_ctx=request.num_ctx or settings.default_num_ctx,
)
lc_messages = service._build_messages(
[m.model_dump() for m in request.messages],
request.system_prompt,
)
async def event_generator():
try:
async for chunk in service.astream(
[m.model_dump() for m in request.messages],
request.system_prompt,
):
if chunk.content:
yield f"data: {json.dumps({'content': chunk.content}, ensure_ascii=False)}\n\n"
yield "data: [DONE]\n\n"
except Exception as e:
logger.exception("stream 接口异常")
yield f"data: {json.dumps({'error': str(e)}, ensure_ascii=False)}\n\n"
return StreamingResponse(event_generator(), media_type="text/event-stream")
流式接口我采用了 SSE(Server-Sent Events)格式,每行以 data: 开头,最后以 data: [DONE] 标记结束。前端和 curl 都可以很方便地消费这个协议。注意流式接口不能用普通的 HTTPException 返回错误,因为响应头已经发出去了,必须在生成器内部把错误包装成 SSE 消息发出去,否则前端拿到的就是一个被截断的响应,很难排查。
3.5 服务实例缓存池
这里解释一下 get_llm_service 的作用。LangChain 的 ChatOllama 实例内部持有 HTTP 连接池,如果每个请求都 new 一个,开销其实不小。但不同请求可能用不同的模型、温度、上下文长度,全共用同一个实例又不合适。所以用 lru_cache 做一个按参数缓存的池子,同一组参数复用同一个实例:
python复制# app/core/service_pool.py
from functools import lru_cache
from app.core.langchain_service import LangChainService
@lru_cache(maxsize=32)
def get_llm_service(model_name: str, base_url: str, temperature: float, num_ctx: int) -> LangChainService:
return LangChainService(
model_name=model_name,
base_url=base_url,
temperature=temperature,
num_ctx=num_ctx,
)
maxsize=32 意味着最多缓存 32 个不同参数组合的服务实例,超出后按 LRU 淘汰最久没用的。实测下来这个方案比每次重新创建快很多,也避免了线程安全问题。LangChain 的 Runnable 在 0.3 版本中本身是线程安全的,实例可以被多个请求复用。
4. 参数调优与性能实践:上下文、采样与并发
4.1 num_ctx 与上下文长度管理
上下文管理是本地大模型服务化最容易出问题的地方。最典型的报错就是:
code复制api error: 400 this model's maximum context length is 1048576 tokens. however, your prompt contains N tokens
这类报错的信息量在于:一方面模型确实有一个支持的最大上下文长度,比如 1048576 tokens 这种超大窗口;另一方面,你实际请求的内容已经逼近或者超过了这个阈值。对 Ollama 场景来说,更常见的是 num_ctx 设置过小——默认值可能只有 2048 或 4096,一旦 prompt 里的历史对话稍微长一点,直接上下文溢出报错。
我的处理策略有三层:
第一,用 ollama show 模型名 查看模型的最大上下文长度。比如 qwen2.5:7b 在 Ollama 中支持到 32768,那我配置 num_ctx 的时候就把它控制在 8192 到 16384 之间,留出余量。
第二,封装层对历史消息做滑动窗口截断。维护一个全局的最大消息轮数配置,超过就丢弃最久远的非 system 消息。也可以用 token 数来更精确地控制,但实现上要额外调用 tokenize 接口,实际收益看场景。
第三,针对确实需要超长上下文的场景,考虑用压缩方式把历史内容做摘要,而不是直接全量送入模型。这个做起来稍微复杂,但效果明显,后面扩展时可以加上。
4.2 采样参数:temperature、top_p 与 top_k
LangChain 的 ChatOllama 初始化时除了 temperature,还能传 top_p、top_k、repeat_penalty 等采样参数。这几个参数直接影响输出的随机性和质量:
| 参数 | 作用 | 实践建议 |
|---|---|---|
| temperature | 控制随机性,值越大输出越发散 | 摘要/抽取类任务用 0.1~0.3,对话/创意类用 0.7~0.9 |
| top_p | 核采样,累积概率阈值 | 一般保持默认 0.9,和 temperature 二选一优先调 |
| top_k | 只从概率最高的 K 个 token 中采样 | 默认 40,偏保守任务可以调低到 20 |
| repeat_penalty | 惩罚重复 token | 默认 1.1,长文本生成时很容易遇到复读机问题,适当调大到 1.3 |
我在封装层没有把这些全部暴露给 API 调用方,因为参数多意味着调用方犯错的空间也大。工程上一个常见做法是只暴露最常用的 temperature,其余参数在服务端维护一组稳定的默认值。这样既保证灵活性,又限制熵增。
4.3 并发控制与显存规划
Ollama 默认情况下对于同一个模型,会自动控制并发请求。当你并发请求打过来时,Ollama 会根据显存情况选择并行处理或排队。如果你的显卡显存比较大,可以通过环境变量 OLLAMA_NUM_PARALLEL 调整并行度,比如:
bash复制OLLAMA_NUM_PARALLEL=4 ollama serve
但并行度不是越高越好。显存不够的情况下强行并行,会导致模型在多请求之间来回换入换出,性能反而比排队更差。而且 Ollama 默认每个请求都会保留一部分 KV cache,并行度太高会把显存吃穿。我这边 24G 显存的卡,跑 7B 模型,实测 OLLAMA_NUM_PARALLEL=2 时吞吐和延迟比较均衡,4 的时候已经开始频繁换出。
另外要注意的是,FastAPI 层的并发和 Ollama 层的并发是两回事。FastAPI 是异步框架,可以同时接收很多 HTTP 请求,但如果 Ollama 底层在排队,前端请求就会一直挂着。所以 API 网关层最好设置合理的请求超时,避免请求堆积把整个服务拖垮。
5. 常见问题与排查技巧实录
5.1 上下文超限报错处理
报错信息形如 api error: 400 this model's maximum context length is ...,排查顺序我建议这样:
- 第一步,看当前模型在 Ollama 里的真实上下文上限,
ollama show qwen2.5:7b输出里有详细参数。 - 第二步,确认封装层传进去的
num_ctx没有超过这个上限,并且小于等于模型支持的最大窗口。 - 第三步,统计本次请求实际的消息总长度。如果历史消息太多,用滑动窗口或摘要压缩。
我遇到过一种比较隐蔽的情况:num_ctx 设置没问题,但用户消息里贴了一整篇几万字的文档,直接把窗口撑爆。这种情况下要做的是在业务层限制单条消息长度,而不是傻傻地调大窗口。
5.2 请求超时与连接断开
本地模型推理速度受硬件影响很大,7B 模型生成几百个 token 可能要几十秒。这时候如果 API 客户端用的默认超时,基本必挂。我踩过的坑总结下来有三类:
- Ollama Python 客户端超时:构造
ollama.Client时显式传 timeout。 - FastAPI 与 Nginx 之间的代理超时:如果前面挂 Nginx,
proxy_read_timeout默认 60 秒,长文本场景也会断。需要把代理超时调大,或者对长任务走流式接口。 - 流式响应中途断开:客户端断连后,后端如果还在继续生成,会浪费推理资源。可以在流式生成器里捕获
ClientDisconnect并提前终止。
5.3 模型加载慢与磁盘空间不足
Ollama 首次请求一个模型时会有一次模型加载过程,这期间请求延迟会非常明显,甚至可能超时。线上做法是提前预热,服务启动时调用一次 /api/generate 传入一个空字符串 prompt,把模型加载进显存:
bash复制curl http://localhost:11434/api/generate -d '{"model": "qwen2.5:7b", "prompt": ""}'
磁盘空间不足则多数出现在模型文件存放目录空间不够。linux 下看模型目录:
bash复制du -sh ~/.ollama/models/
如果模型文件确实很大,把 OLLAMA_MODELS 环境变量指向数据盘,再重启服务。Windows 下就是 2.4 节说的 OLLAMA_MODELS=D:\ollama\models 方案。
5.4 API 鉴权与模型名管理
内部服务虽然在内网,但接口鉴权不能省。我用的方案最简单——Header 里传 Authorization: Bearer xxx,配置里存一个 api_token。后续如果多个团队接入,可以换成按团队分配不同 key,这里就不展开了。
模型名管理容易被忽略。如果调用方传入一个不存在的模型名,Ollama 会尝试去拉取这个模型,这是个很大的隐患——可能把模型仓库里没有的模型名当作新请求拉取,长时间占用网络和磁盘。封装层应该先调用 list_models 做白名单校验,非法模型名直接返回 400:
python复制def check_model_exists(model_name: str, settings: Settings):
client = OllamaClient(base_url=settings.ollama_base_url, timeout=10)
models = client.list_models()
if model_name not in models:
raise HTTPException(status_code=400, detail=f"model {model_name} not found")
实测下来这个校验很有必要,防止调用方手滑写错模型名导致莫名其妙触发拉取。
6. 从基础对话到 RAG 与 Agent 的扩展路径
封装层现在的实现只是最基础的对话链路。接下来比较自然的方向是 RAG(检索增强生成)和 Agent 工具调用。这两块 LangChain 都提供了比较成熟的组件,结合我们现有的封装层,改动路径也很清晰。
RAG 的思路是把需要引用的文档切块、向量化后存入向量库,每次请求先检索出最相关的片段,再连同问题一起塞给 LLM。LangChain 这边可以用 langchain-community 里的向量库封装,加上 Ollama 的 embedding 模型,快速搭建一套内部知识库问答。我在代码里预留了 chat_with_template 这个方法,就是为了接 prompt 模板用的。
Agent 则是让模型具备调用外部工具的能力,比如查数据库、调内部接口。LangChain 的 Agent 编排在 langgraph 体系中更灵活,但核心思路不变:把工具函数绑定到模型,让模型自己决定何时调用。
这些扩展不建议一上来就做。每次新增能力之前,先想清楚业务方是否真的需要。等内部调用量上来、知识库场景明确之后,再逐步迭代。毕竟本地大模型服务的核心目标,是稳定、可控、够用,而不是堆功能。
最后再分享一个我个人的习惯:接口层任何参数变更,都在 README 里同步更新 curl 示例。我见过太多团队接口改了文档没改,调用方来来回回排查半天,最后发现是自己传参格式错了。API 封装这件事,真正重要的不只是代码结构,还有沟通成本和可维护性。把自己的经验沉淀成文档,后面接手的同学能少踩一半坑。
