很多人第一次把模型跑起来之后,卡住的地方不是"怎么装",而是"装完之后怎么用"。命令行里敲一句对话,看着输出一个字一个字蹦出来,感觉挺新鲜,但真要把这个能力接进自己的程序里,就发现无从下手了。我见过不少朋友在这一步绕了很久:要么是拿命令行当接口用,靠脚本去解析终端输出,脆弱得不行;要么是直接上各种封装库,结果出了问题根本不知道是哪一层在捣鬼。这篇就聊透一件事——绕开所有中间层,直接用 Ollama 的原生 HTTP API 来做应用开发。把这一层搞明白,上面那些封装库你都能看得清清楚楚,出问题也知道去哪儿找。
Ollama 原生 API 的价值在于它足够"薄"。它本质上就是一个跑在本地的 HTTP 服务,默认监听一个固定端口,你用任何能发 HTTP 请求的语言都能跟它对话。这意味着你不需要绑定某个特定语言的 SDK,Python、Node、Go、甚至浏览器里的 fetch 都能直接调。对于做 AI 应用开发的人来说,这层接口是你理解"模型服务"这件事的最佳切入点:请求怎么组织、流式响应怎么处理、上下文怎么维护、参数怎么调,全都在这几个端点里体现得明明白白。适合谁看?如果你已经能把模型拉起来跑通命令行,想进一步把它变成自己产品里的一个功能模块,那这篇就是给你准备的。哪怕你之前没写过多少后端代码,只要会用 curl 或者 Postman 发请求,就能跟着走下来。
1. 先搞清楚这个本地服务到底暴露了什么
在动手写代码之前,我建议先花十分钟把服务的"地形"摸清楚。很多人上来就抄一段请求代码,跑通了也不知道自己调的是哪个端点、参数从哪儿来的,后面一出错就抓瞎。先把这层看明白,后面所有操作都是水到渠成。
1.1 默认端口与几个核心端点
Ollama 启动之后,会在本机起一个 HTTP 服务,默认端口是11434。你可以直接在浏览器里访问http://localhost:11434,会看到一行简单的提示文字,这说明服务活着。这个动作看似没用,但它是最快的连通性检查——如果这行字都出不来,那后面所有请求都不用试了,先去看服务有没有起来。
真正干活的核心端点其实不多,我按使用频率排一下:
| 端点 | 方法 | 用途 | 使用频率 |
|---|---|---|---|
/api/generate | POST | 单轮文本生成,最基础的对话 | 极高 |
/api/chat | POST | 多轮对话,带角色消息结构 | 极高 |
/api/tags | GET | 列出本地已有模型 | 高 |
/api/show | POST | 查看某个模型的详细信息 | 中 |
/api/pull | POST | 拉取模型 | 中 |
/api/embeddings | POST | 生成文本向量 | 中 |
/api/ps | GET | 查看当前加载在内存里的模型 | 低但有用 |
这张表建议存下来。日常开发里 90% 的工作就是围绕/api/generate和/api/chat这两个端点转。前者适合"一问一答"式的场景,比如文本改写、摘要、翻译;后者适合需要保留对话历史的场景,比如客服机器人、多轮问答助手。两者的区别后面会细讲,这里先有个印象。
1.2 为什么是 HTTP 而不是别的形式
有人会问,为什么不做成某种本地函数调用,非要走 HTTP?这其实是个很聪明的设计取舍。走 HTTP 意味着语言无关、进程隔离、可以远程访问(同一台机器上的其他进程、甚至局域网内的其他设备)。你的应用和模型服务是两个独立进程,模型崩了不会拖垮你的应用,你的应用重启也不用重新加载模型。这种解耦在实际部署里非常值钱。
我自己的习惯是,把模型服务当成一个"外部依赖"来看待,就像你用数据库一样。你的代码里不会去直接操作数据库的磁盘文件,而是通过连接去发查询。同理,你的应用不该去关心模型文件在哪、怎么加载,只管往这个 HTTP 端点发请求、收结果。想清楚这层关系,架构上就不会乱。
1.3 请求和响应的基本形状
所有 POST 端点的请求体都是 JSON,响应默认也是 JSON。最基础的/api/generate请求长这样:
curl http://localhost:11434/api/generate -d '{ "model": "你的模型名", "prompt": "用一句话解释什么是递归" }'注意model这个字段是必填的,它决定了用哪个模型来处理。这个模型名必须是你本地已经拉取过的,否则会报错。可以先调/api/tags看看手头有哪些模型可用。响应体里除了生成的内容,还会带一些元信息,比如耗时、token 统计等,这些在后面做性能分析时很有用。
提示:如果你不确定本地有哪些模型,先
GET /api/tags,返回的 JSON 里models数组每一项的name字段就是可用的模型名。别凭记忆瞎填,名字差一个字符都会报 model not found。
2. 单轮生成:把 /api/generate 用透
/api/generate是最容易上手的端点,但它的参数远比表面看起来丰富。很多人只用model和prompt两个字段,其实浪费了一大半能力。这一节把常用参数一个个拆开讲,每个都说清楚"调了会怎样、什么时候该调"。
2.1 最简请求与流式开关
先看一个能跑通的最小例子,用 Python 的 requests 库:
import requests import json resp = requests.post( "http://localhost:11434/api/generate", json={ "model": "你的模型名", "prompt": "写一段关于秋天的短诗", "stream": False } ) print(resp.json()["response"])这里的关键是stream参数。默认情况下它是true,也就是流式输出——服务会一行一行地往回吐 JSON,每行包含一小段生成的内容。设成false之后,服务会等全部生成完,一次性返回完整结果。两种模式各有用途:做命令行工具、聊天界面这种需要"打字机效果"的场景,用流式;做批处理、后台任务这种只要最终结果的场景,用非流式更省心。
流式模式下,响应不是标准的一个 JSON 对象,而是多行 JSON(每行一个独立对象),最后一行会带done: true标记结束。处理的时候要按行读取、逐行解析,不能直接resp.json(),那样会报错。这是新手最常踩的坑之一,我第一次用的时候也在这儿卡了半天。
2.2 流式响应的正确处理姿势
流式处理写对了其实不难,关键是别把整个响应体当成一个 JSON。正确做法是逐行迭代:
import requests import json with requests.post( "http://localhost:11434/api/generate", json={ "model": "你的模型名", "prompt": "讲一个三句话的冷笑话", "stream": True }, stream=True ) as resp: for line in resp.iter_lines(): if not line: continue chunk = json.loads(line) if "response" in chunk: print(chunk["response"], end="", flush=True) if chunk.get("done"): break这段代码有几个细节值得说。stream=True在 requests 这一侧也要设,否则它会把整个响应缓冲下来再给你,流式就失去意义了。iter_lines()按行切分,空行要跳过。每个 chunk 里response字段是这一小段文本,done字段标记是否结束。flush=True保证输出立刻显示,不然 Python 的输出缓冲会让"打字机效果"变成"憋一大坨再吐出来"。
注意:流式响应里,最后一个 chunk 的
response字段通常是空字符串,真正的结束信号是done: true。别指望靠"response 为空"来判断结束,那样会提前退出,丢掉最后一段内容。
2.3 控制生成行为的几个关键参数
除了model和prompt,还有一组参数直接决定生成的质量和风格。这些参数放在请求体的options对象里:
{ "model": "你的模型名", "prompt": "写一段产品介绍", "options": { "temperature": 0.7, "top_p": 0.9, "num_predict": 512, "seed": 42 } }temperature控制随机性,值越低输出越确定、越保守,值越高越发散、越有创意。做事实性问答、代码生成,我一般压到 0.2 到 0.4;做创意写作、头脑风暴,放到 0.8 到 1.0。top_p是另一种采样控制,和 temperature 配合用,通常保持 0.9 左右就行,不用频繁动它。
num_predict限制最多生成多少个 token,相当于给输出长度封顶。这个参数很重要,不设的话模型可能一直生成到上下文上限,既慢又费资源。做摘要这类任务,我一般按预期长度的 1.5 倍来设,留点余量。
seed是随机种子,设了之后同样的输入能复现同样的输出(在 temperature 不为 0 的前提下也不完全保证,但能大幅提高一致性)。做测试、做对比实验的时候,固定 seed 能帮你排除随机因素的干扰,这个技巧在调 prompt 时特别有用。
2.4 系统提示词与模板控制
/api/generate支持通过system字段传入系统提示词,用来设定模型的角色和行为边界:
{ "model": "你的模型名", "system": "你是一个严谨的技术文档编辑,回答简洁,不说废话。", "prompt": "解释一下什么是幂等性" }系统提示词的作用是给整个对话定调。实测下来,把角色、输出格式、禁忌事项写进 system,比塞进 prompt 里效果更稳定。比如要求"只输出 JSON,不要任何解释",放在 system 里模型遵守得更好。
还有一个template字段,用来覆盖模型自带的提示词模板。这个一般不用动,除非你在做很底层的定制。默认模板已经处理好了指令格式,乱改反而容易出问题。我建议除非明确知道自己在干什么,否则别碰这个字段。
3. 多轮对话:/api/chat 的消息结构才是重点
单轮生成够用一阵子,但真正做应用,多轮对话是绕不开的。/api/chat和/api/generate最大的区别在于,它接收的是一个消息数组,而不是单个字符串。这个结构直接决定了你怎么管理对话历史。
3.1 messages 数组的角色划分
/api/chat的请求体核心是messages字段,它是一个数组,每个元素有role和content两个字段。role有三种取值:system、user、assistant,分别代表系统设定、用户输入、模型回复。
{ "model": "你的模型名", "messages": [ {"role": "system", "content": "你是一个耐心的编程助教。"}, {"role": "user", "content": "什么是闭包?"}, {"role": "assistant", "content": "闭包是指函数与其词法环境的组合……"}, {"role": "user", "content": "能举个 JavaScript 的例子吗?"} ] }这个结构的关键在于:对话历史是你自己维护的。服务端不保存任何状态,每次请求你都要把完整的历史带上。模型看到的是你传过去的整个 messages 数组,它据此生成下一条回复。这一点和某些托管服务不同,那些服务会给你一个会话 ID,帮你记历史;Ollama 这边是无状态的,历史管理完全在你手里。
这个设计初看麻烦,实际很灵活。你可以自由裁剪历史(比如只保留最近 10 轮)、可以插入伪造的 assistant 消息来引导模型、可以在不同会话间复用历史。控制权在你,代价是你得自己写管理逻辑。
3.2 自己维护对话历史的完整实现
下面是一个能持续对话的最小实现,把历史存在一个列表里,每轮追加:
import requests history = [ {"role": "system", "content": "你是一个简洁的助手,回答不超过三句话。"} ] def chat(user_input): history.append({"role": "user", "content": user_input}) resp = requests.post( "http://localhost:11434/api/chat", json={ "model": "你的模型名", "messages": history, "stream": False } ) reply = resp.json()["message"]["content"] history.append({"role": "assistant", "content": reply}) return reply print(chat("你好")) print(chat("刚才我问了什么?"))注意响应结构和 generate 不同:chat 的回复在message.content里,而不是顶层的response。这个差异很多人第一次会搞混,调试时对着响应体多看一眼就清楚了。
第二句"刚才我问了什么"能答对,说明历史确实被带上了。如果答不上来,八成是历史没追加对,或者请求里漏传了 messages。这是验证历史管理是否生效的最简单测试,建议每次改完历史逻辑都跑一遍。
3.3 历史裁剪:上下文窗口的现实约束
历史不能无限增长。每个模型都有上下文窗口上限,超了要么报错,要么被静默截断。而且历史越长,每次请求要处理的 token 越多,响应越慢、越费资源。所以实际应用里必须做裁剪。
最简单的策略是保留最近 N 轮,超出就丢最早的。但这样会丢掉 system 消息和早期的关键设定,所以裁剪时要特殊处理 system:
def trim_history(history, max_turns=10): system_msgs = [m for m in history if m["role"] == "system"] dialog_msgs = [m for m in history if m["role"] != "system"] if len(dialog_msgs) > max_turns * 2: dialog_msgs = dialog_msgs[-max_turns * 2:] return system_msgs + dialog_msgsmax_turns * 2是因为一轮对话包含一条 user 和一条 assistant。这个策略简单粗暴但有效,适合大多数场景。更精细的做法是按 token 数裁剪,但那需要先算 token,复杂度上一个台阶,除非你的应用对上下文长度特别敏感,否则没必要。
提示:裁剪历史时一定要把 system 消息单独拎出来保留。我见过有人直接对数组切片,结果把 system 消息切没了,模型行为立刻变得不受控,排查半天才发现是历史裁剪的锅。
4. 参数调优与结构化输出:让结果可编程
模型能说话是一回事,说的话能被程序稳定解析是另一回事。做应用开发,你最终要的是结构化数据,不是一段自由文本。这一节讲怎么通过参数和提示词,把输出约束成可解析的格式。
4.1 format 参数与 JSON 输出
Ollama 的 API 支持一个format字段,设成json之后,模型会被约束为输出合法的 JSON:
{ "model": "你的模型名", "prompt": "把这句话里的信息提取成 JSON:张三,28岁,住在杭州。", "format": "json", "stream": false }设了format: json之后,返回的response字段就是一个 JSON 字符串,可以直接json.loads解析。这个功能在做信息抽取、结构化转换时非常省事。但要注意,它只保证"是合法 JSON",不保证"字段符合你的预期"。所以提示词里还是要把字段名、类型写清楚,否则模型可能给你一个合法但结构不对的 JSON。
我一般的做法是,在 prompt 里给一个明确的 schema 示例:
请提取以下文本中的人物信息,输出 JSON,字段为 name(字符串)、age(整数)、city(字符串)。 文本:张三,28岁,住在杭州。配合format: json,命中率很高。实测下来,给了字段说明和类型,比只写"输出 JSON"稳定得多。
4.2 用 JSON Schema 做更强的约束
较新版本的 Ollama 支持在format字段里直接传一个 JSON Schema 对象,而不只是字符串"json"。这能把输出约束得更死:
{ "model": "你的模型名", "prompt": "提取人物信息:李四,35岁,北京。", "format": { "type": "object", "properties": { "name": {"type": "string"}, "age": {"type": "integer"}, "city": {"type": "string"} }, "required": ["name", "age", "city"] }, "stream": false }这样模型输出必须符合这个 schema,字段名、类型都被约束住了。做数据管道、自动化处理时,这个能力能省掉大量后处理代码。不过要注意,schema 越复杂,模型遵守的难度越大,字段别设太多,嵌套别太深,否则反而容易出错。
4.3 参数组合的实战取舍
参数调优没有万能公式,但有一些经验规律可以少走弯路。我整理了一张常用场景的起点配置:
| 场景 | temperature | top_p | num_predict | 备注 |
|---|---|---|---|---|
| 事实问答 | 0.2 | 0.9 | 256 | 求稳,别发散 |
| 代码生成 | 0.3 | 0.9 | 1024 | 留足长度 |
| 创意写作 | 0.9 | 0.95 | 512 | 放开随机性 |
| 信息抽取 | 0.1 | 0.9 | 256 | 配合 format: json |
| 翻译 | 0.3 | 0.9 | 512 | 稳定优先 |
这些只是起点,实际还得根据模型和任务微调。我的习惯是先用低 temperature 跑通,确认输出结构对了,再逐步调高看质量是否提升。反过来先调高再往低收,容易一开始就被发散的结果带偏,判断不了是参数问题还是提示词问题。
5. 踩过的坑与排查链路
这一节讲几个我实际遇到过的问题,以及完整的排查过程。这些坑在文档里基本不会写,但实际开发中很容易撞上。
5.1 请求超时:不是服务挂了,是生成太慢
第一次遇到请求超时,我以为是服务没起来,反复重启服务、检查端口,折腾半天。后来才发现是生成时间太长,客户端等不及先断了。默认情况下,长文本生成可能要几十秒甚至更久,如果客户端设了较短的超时,就会提前报错。
排查链路是这样的:先用 curl 直接请求,看能不能拿到结果。curl 默认不设超时,如果 curl 能出结果而代码不行,那问题就在客户端的超时设置上。Python requests 默认没有超时(会一直等),但很多封装库会设默认超时。解决办法是显式设一个足够长的超时,或者干脆用流式,边收边处理,避免长时间空等。
resp = requests.post(url, json=payload, timeout=300)流式模式下,超时的含义变成"两次数据之间的最大间隔",通常设个 60 秒就够,因为流式会持续有数据回来。
5.2 模型名写错:报错信息其实很明确
model not found这个错误,我见过不少人第一反应是"模型没下载",然后去重新拉取,结果拉取也失败,因为名字本来就写错了。正确的排查顺序是:先GET /api/tags列出所有模型,把返回的name字段原样复制过来用。注意有些模型名带:latest后缀,有些带具体版本号,别自己拼。
还有一种情况是模型确实在本地,但服务没识别到。这时候GET /api/ps看看当前加载的模型列表,如果列表是空的,说明服务可能刚启动还没加载任何模型,发一次请求触发加载即可。
5.3 流式解析报错:把多行 JSON 当成了单个对象
前面提过,流式响应是多行 JSON,不是单个 JSON。但实际写代码时,很容易顺手写成resp.json(),然后报JSONDecodeError。这个错误的排查很简单:把原始响应打印出来看一眼,如果看到多行、每行一个{...},那就是流式格式,得按行解析。
我建议在开发阶段,先关掉流式(stream: false)把逻辑跑通,确认请求参数、响应结构都对,再打开流式改解析逻辑。分两步走,比一上来就调流式容易定位问题。
5.4 中文乱码与编码问题
偶尔会遇到返回的中文变成乱码。这通常不是模型的问题,而是客户端解码方式不对。requests 一般能自动处理,但如果手动处理字节流,要确保用 UTF-8 解码。流式处理时,iter_lines()默认按字节切分,如果内容里有跨行的多字节字符,可能切坏。稳妥的做法是用iter_lines(decode_unicode=True),或者自己按字节缓冲再解码。
注意:处理流式中文时,别假设每个 chunk 都是完整的字符边界。多字节字符可能被切在两个 chunk 之间,直接逐 chunk 解码会出乱码。用带解码的迭代器能规避这个问题。
6. 把 API 接进真实应用的几个设计考量
跑通 demo 只是开始,真要把这套东西接进产品,还有几个设计层面的问题要想清楚。这一节聊的是架构和工程上的经验,不是 API 本身的用法。
6.1 服务发现与配置外置
硬编码http://localhost:11434在本地开发没问题,但一旦要部署到别的环境,就得改代码。我的习惯是把服务地址、模型名、超时时间这些全部外置成配置,代码里只读配置。这样换环境只改配置,不动代码。
更进一步,可以做一个简单的健康检查:应用启动时先 ping 一下服务,确认可达再开始接请求。服务不可达时给出明确提示,而不是让用户等到第一次请求才看到莫名其妙的错误。这个检查成本很低,但能省掉很多"为什么没反应"的困惑。
6.2 并发请求的处理
Ollama 服务本身能处理并发请求,但并发数上去之后,每个请求的响应会变慢,因为底层资源是共享的。做应用时要想清楚并发策略:是排队串行处理,还是限制最大并发数,还是直接拒绝超额请求。
我的经验是,本地部署的场景下,并发数控制在个位数比较稳妥。再高的话,要么加机器,要么换更强的硬件。盲目提高并发只会让所有请求都变慢,用户体验反而更差。可以在应用层做一个简单的信号量控制,超过阈值就排队。
6.3 错误处理与重试策略
模型服务不是永远可靠的,可能因为内存不足、模型加载失败、请求格式错误等各种原因返回错误。应用层要有兜底:对可重试的错误(比如临时资源不足)做有限次重试,对不可重试的错误(比如请求格式错)直接返回明确提示。
重试要加退避,别连续猛发。简单的指数退避就够用:第一次等 1 秒,第二次 2 秒,第三次 4 秒,最多重试三次。这样既给了服务恢复的时间,又不会让用户等太久。
6.4 响应缓存:省资源也提速度
同样的输入反复请求,结果基本一样(temperature 为 0 时完全一样)。这种场景下加一层缓存很划算:把请求的哈希作为 key,响应作为 value 存起来,命中就直接返回。做批量处理、做测试、做演示时,缓存能省掉大量重复计算。
缓存要注意失效策略。模型换了、参数改了,缓存就得清。简单做法是把模型名和关键参数也纳入 key 的计算,这样不同配置自然对应不同缓存,不会串。
7. 一些不那么显然的实用技巧
最后分享几个零散但好用的技巧,都是实际用下来觉得值的。
第一个是善用/api/show查模型细节。不同模型的上下文窗口、参数量、量化方式都不一样,这些信息在/api/show的返回里能看到。做长文本处理前先查一下上下文上限,避免请求被截断还不知道原因。
第二个是批量任务用非流式加并发控制。批量处理几百条文本时,流式反而增加了解析复杂度,非流式配合适度的并发,整体吞吐更高。我一般用线程池控制并发数,每个任务独立请求,收集结果后统一处理。
第三个是调试时固定 seed。调 prompt 的时候,随机性会干扰判断——你以为是 prompt 改好了,其实只是这次随机到了好结果。固定 seed 之后,同样的输入输出稳定,改动的效果才能被准确评估。等 prompt 定型了再去掉 seed 看真实表现。
第四个是注意模型加载的冷启动。服务刚启动或长时间没请求后,第一次请求会触发模型加载,耗时明显更长。做性能测试时要排除这个因素,否则数据不准。可以先发一个预热请求,等模型加载完再开始正式测试。
这套原生 API 用熟之后,你会发现很多上层工具其实就是在这几个端点外面包了一层。理解了这一层,再去看那些封装库的文档,会有种"原来如此"的通透感。遇到问题也知道该往哪一层找,而不是在抽象层之间瞎猜。这大概就是直接啃原生接口最大的回报。