兜兜转转试了一圈云端接口之后,我最近把 Agent 实验项目的主推理链路切到了本地模型上。Ollama 作为本地推理入口,配合 Python 调用,再加上一套不会轻易被模型“发挥”改写的提示词模板,是目前我自己实践下来性价比最高的组合。这篇记录就是完整走一遍这条学习路线:从安装 Ollama、选模型、用 Python 调接口,到提示词模板和简单 Agent 循环怎么串起来。适合这几类人阅读:刚开始接触本地大模型的初学者、想把模型集成进自动化脚本或下游服务的开发者、以及被云端 API 的上下文长度、费用和接口返回格式反复折磨的动手党。通篇没有平台依赖,所有代码都是可以直接复制落地的最小实现。
1. 为什么本地模型 + Agent 这条路值得走
1.1 本地推理解决的三个实际问题
过去一年里“本地模型”这四个字从玩具变成了可用的工具,主要是因为它解决了三个我在调 Agent 时躲不开的痛点。
第一个是成本。Agent 和普通问答最大的区别在于它会循环:拿到结果、判断、再调一次模型、继续执行。一个复杂任务可能换来十几次甚至几十次模型调用。如果每次都走云端计费接口,费用会随着调试次数直线上升。本地推理的心理压力完全不同,我改成本地后,调试提示词时基本是放开了跑,不再像以前那样每次提交都肉疼。
第二个是数据边界。有些任务场景是拿内部资料做摘要、分类、抽取,这些内容不管有没有明文要求,都不太合适频繁往外发。本地模型让整个链路保持在笔记本或工作站内部,至少在权限边界上少了一环操心。
第三个是可重复性。云端接口的参数和模型版本更新节奏不完全由你控制,今天调通的提示词明天换了一个版本后可能输出风格就变了。Ollama 本地模型只要锁住镜像版本,行为是可以稳定复现的。这对调试 Agent 很关键,因为我需要把“模型行为差异”和“代码逻辑问题”这两类错误分开排查。
用一句不太准确但容易理解的话来说:本地模型就像在自己家里装了一套净水设备,云端模型则是定期从超市买桶装水。前者要多花点时间维护,但取用自由、成本稳定、流程可控;后者省事,但要持续花钱,而且断供时只能干等。
1.2 提示词模板在 Agent 链路里的位置
很多人在 Agent 项目里只顾着搭流程,却不太重视提示词是怎么组装的。其实提示词模板才是把“会聊天的大模型”变成“能干活的任务执行器”的那根传动轴。
一个最小 Agent 链路大概是:接收任务 -> 组装上下文 -> 模型推理 -> 解析结果 -> 判断是否需要继续调用工具 -> 更新上下文 -> 再次推理。在这个循环里,每次模型推理前输入给模型的内容,都是靠提示词模板拼出来的。模板里不但要包含系统设定的角色和约束,还要把当前任务、历史步骤、工具列表、上一轮结果这些动态内容按固定格式填进去。
如果不把提示词模板当工程来做,直接拿字符串拼接,Agent 写两层之后就会乱。比如用户输入里混入了特殊符号,或者在多轮对话里忘了截断历史,模型就开始自由发挥,输出格式一乱,后面的 JSON 解析直接报错。所以我把提示词模板看成和模型调用同等重要的独立模块,先定格式,再写业务逻辑。
2. 从零配置 Ollama 环境与模型管理
2.1 安装 Ollama 与基础命令
Ollama 在不同平台上的安装方式有所差异,但整体都不复杂。在 macOS 和 Windows 上有桌面安装包,下载后双击按提示安装就行。Linux 服务器上我通常是下载官方安装脚本执行。
安装完成后先别急着拉模型,先把命令跑通。
ollama --version这一条能确认可执行文件是否在 PATH 里。接着建议做一件事:把模型存储目录从系统盘挪走。默认情况下模型会下载到当前用户目录下,如果系统盘空间不大,后面很容易出问题。
# Linux / macOS 示例,Windows 在系统设置里配置用户环境变量 export OLLAMA_MODELS=/data/ollama/models配置好存储目录后,重启 Ollama 服务或重新打开终端,再看几条基础命令:
ollama list # 查看本地已有模型 ollama pull qwen2.5:7b # 拉取指定模型 ollama run qwen2.5:7b # 命令行交互式对话 ollama ps # 查看当前加载在内存中的模型 ollama stop qwen2.5:7b # 释放模型占用玩命令行很久的人应该都有这种感觉:越是一条命令能做好的事,越不需要额外 GUI 去打扰。Ollama 的命令行设计就是这么直白,跑过一遍就能记住。
2.2 模型选型和拉取参数
模型选型是第一步就要踩的坑。我对新手的第一条建议是:不要想着一步到位拉最大的模型,先拉一个小参数版本把整条链路走通。宁可后面换大模型,也不要在环境都没验证时直接下载二十个 G 的权重。
Ollama 的模型标签可以按参数规模和量化方式区分,常见的像 3b、7b、8b 这类字样代表参数量,数字越小对内存和显卡的要求越低。另一个要注意的是量化后缀,比如 q4_K_M、q8_0,这代表权重的压缩方式。量化程度越高模型文件越小,速度和内存占用更友好,但精度会稍微损失。做 Agent 工具调用场景时,q4_K_M 这个档位通常足够了。
这里给一张我日常用下来的选择参考表:
| 模型标签 | 参数量 | 适合场景 | 内存/显存经验值 |
|---|---|---|---|
| qwen2.5:3b 或 7b | 3B / 7B | 中文对话、工具调用、JSON 输出 | 3B 约 3GB 起步,7B 约 6GB 起步 |
| llama3.2:3b | 3B | 英文内容理解,轻量任务 | 2-4GB |
| phi3:mini | 3.8B | 偏逻辑和代码生成 | 3-5GB |
| 更大参数比如 14b、30b | 14B+ | 复杂推理,但对硬件要求高 | 至少 12GB 起步,CPU 环境会很慢 |
拉取模型时用冒号加标签指定版本。我实际跑下来的命令大概是:
ollama pull qwen2.5:7b如果只写qwen2.5,会默认拉取官方给的标准主标签。建议明确写全标签,不要在没确认版本时直接拉默认值。
提示:不要看模型下载完成就去
ollama run。先检查一下本机 GPU 显存和可用内存,在命令行里跑通一次完整对话后,再进入 Python 集成阶段。
2.3 服务端配置与运行时观察
Ollama 本质上是一个本地推理服务,默认监听在127.0.0.1:11434。Python 调用其实就是在消费这个服务接口,所以先把这个服务端行为弄明白会省很多事。
我在实际项目里一般会做两个调整。第一是设置环境变量OLLAMA_HOST,把监听地址改成局域网内可访问的 IP,方便同一台机器上的容器或物理机共享调用。第二是OLLAMA_KEEP_ALIVE,默认模型会在一段时间不活跃后从内存卸载,如果我希望模型长期驻留,就把这个值设长一点。
export OLLAMA_HOST=0.0.0.0:11434 export OLLAMA_KEEP_ALIVE=30m启动服务后,可以直接用curl确认服务活着:
curl http://localhost:11434/api/tags如果返回了一串 JSON,里面包含已下载模型的列表,说明服务正常。这一步很多人容易卡住,因为 Python 报连接失败时,第一反应是代码写错了,其实八成是服务没起来或监听地址不对。
内存和显存的观察也很重要。模型推理时会消耗显存,如果显存不够会回退到 CPU。可以用ollama ps查看当前到底有哪些模型被加载,以及它占用了多少空间。这个命令能帮你在“模型请求超时”和“服务响应慢”之间做出初步判断。
3. Python 调通本地模型的核心写法
3.1 官方 ollama 库的最小可用代码
环境准备好后,Python 侧最简单的方式是直接安装官方提供的 Python 库。
pip install ollama这个库封装了底层 HTTP 调用,接口和原生的 OpenAI Chat Completions 风格很像,但更精简。先看一个最小可用代码:
import ollama response = ollama.chat( model='qwen2.5:7b', messages=[ {'role': 'system', 'content': '你是一个擅长把复杂问题总结成三句话的助手。'}, {'role': 'user', 'content': '用三句话总结一下本地大模型的核心优势。'}, ], ) print(response['message']['content'])跑这段代码的时候有个容易误会的点:返回对象是一个字典,不是封装好的对象。response['message']['content']里面才是模型输出文本。很多人第一次写到response.message.content报错,就是因为没注意 Python 库的返回数据结构。
这个库最方便的地方是它会自动处理一些协议细节,不需要我手动拼 URL、构造 headers。对快速原型和内部工具来说,这个 API 已经足够。但如果我要做更细的控制,比如设置并发、管理自定义请求头,直接走原生 REST 会更自由。
3.2 走 REST API 的方式与流式输出
官方库足够应付日常,但把它拆开看,本质是向/api/chat这个地址发 POST 请求。有些场景我会直接用requests或httpx调原生接口,好处是任何语言都能写,不需要额外依赖 Python 库。而且流式聊天接口用原生请求看得更清楚。
流式输出对大语言模型非常重要。如果关掉流式,模型要把完整结果生成完才能一次性返回,Agent 执行时间长时体验很差。打开流式后,模型边生成边返回,可以实时观察输出情况。
import requests resp = requests.post( 'http://localhost:11434/api/chat', json={ 'model': 'qwen2.5:7b', 'messages': [ {'role': 'system', 'content': '你是一个简洁严谨的助手。'}, {'role': 'user', 'content': '解释一下什么是提示词模板。'}, ], 'stream': True, }, stream=True, ) for line in resp.iter_lines(): if not line: continue chunk = line.decode('utf-8') # Ollama 流式返回的是 NDJSON,每行一个 JSON 对象 print(chunk)这个接口返回的数据是每行一个 JSON,需要逐行解析。如果是 Agent 项目,更好的做法是不去手动打印,而是把每次content增量收集起来,拼成最终的完整输出。判断结束的标记是返回数据里的done字段,或者看done_reason是否存在。
另外,Ollama 也提供了 OpenAI 兼容接口,路径通常在/v1/chat/completions或者/api/chat中带上了 OpenAI 风格的参数。这个对我的价值在于:先把代码构造成兼容 OpenAI SDK 的格式,哪天想切换回云端接口,改动成本会低很多。
3.3 长会话上下文与对话历史管理
本地模型看起来便宜,但上下文窗口和显存仍在约束你。模型能够“记住”多少内容取决于上下文窗口长度,以及每轮对话后显存的占用增长。如果不管理历史消息,多轮之后就很容易报显存不足或生成质量下降。
一个比较通用的做法是裁剪历史:只把最近几轮对话传给模型,更老的会话可以在工具层做摘要,再把摘要当成新的历史塞进系统提示词里。
def build_messages(system_prompt, history, user_query, max_rounds=4): messages = [{'role': 'system', 'content': system_prompt}] for user_text, assistant_text in history[-max_rounds:]: messages.append({'role': 'user', 'content': user_text}) messages.append({'role': 'assistant', 'content': assistant_text}) messages.append({'role': 'user', 'content': user_query}) return messages这段代码逻辑不复杂,但它是 Agent 多轮对话里最基础的底座。max_rounds不是一个死值,需要根据模型上下文大小、每轮文本长度和任务复杂度来调整。我常用的 7B 模型一般保留 4 到 6 轮原始对话,超出之后就塞一段摘要,效果比直接截断好很多。
还有一些适合 Agent 的细节:如果对话里包含工具返回的内容,最好给每轮结果增加一个明确的标记,让模型知道哪部分是用户输入、哪部分是工具输出。不然多轮之后模型会混淆来源,把工具返回的数据当成自己说的话,后面行为会越来越怪。
4. 提示词模板:把“会聊”变成“会干活”
4.1 系统提示词与动态变量拼接
现在进入整条链路里最容易被低估的部分:提示词模板。
我最开始的写法是直接把整段提示词写成一个超长字符串,变量的地方用 f-string 拼。这种方式能在五分钟内跑通,但代码稍长就不可维护。变量一多,你会发现有的变量没渲染,有的变量被模型错误理解成指令本身。
一个更结构化的做法是把系统提示词拆成基础角色、任务约束、动态上下文三部分。每次调用模型前,用模板引擎把动态部分填进去。
base_prompt = """ 你是{role}。 任务目标:{task} 可用工具:{tools} 执行规则: 1. 必须基于已有信息判断。 2. 如果信息不足,明确回答“信息不足”,不要编造。 3. 输出必须按照指定格式。 背景信息:{background} """这是一段“骨架模板”,变量有role、task、tools、background。拼接时我会用一个函数统一处理,而不是到处写散落的 f-string。
def render_prompt(template, **kwargs): return template.format(**kwargs) prompt = render_prompt( base_prompt, role="数据分析助手", task="统计最新一轮测试的通过率", tools="get_test_results()", background="项目从 3 月进入稳定期", )这里有一个经验:不要把不可信的用户输入直接往模板里塞。如果用户输入里包含类似“忽略以上指令”的内容,而你构建的提示词又刚好把这个输入塞进了“任务目标”字段,模型就容易被带偏。比较好的做法是对输入做一层翻转或清洗,至少要把控制类符号和超长内容做个截断。
4.2 结构化输出与角色扮演提示模板
在 Agent 项目里,模型输出不能是自由文本,因为下游代码要解析它、判断它、执行对应动作。让模型输出自然语言再靠规则去猜是极其脆弱的。我现在的做法是尽量用结构化格式,最常用的是 JSON。
Ollama 对结构化输出有原生支持,可以在请求参数里加一个format字段。指定为 JSON 后,模型会被约束成只输出合法 JSON,这个特性对业务落地很关键。
response = ollama.chat( model='qwen2.5:7b', messages=[ {'role': 'system', 'content': '只能输出 JSON,不要输出任何解释文字。'}, { 'role': 'user', 'content': ( '从这段文本中提取“主题”和“紧急程度”,' '返回格式:{"theme": "...", "priority": "high|medium|low"}。\n' '文本:' + raw_text ), }, ], format='json', ) import json result = json.loads(response['message']['content'])很多模型在用format='json'之后还是会在 JSON 外包装一层 Markdown 代码块,比如json ...。遇到这种情况,与其在模型提示词里反复强调,不如在后处理时做一个通用清洗函数,去掉代码块标记再解析。
角色扮演模板也很实用。Agent 场景里不是真的要模拟某个虚构角色,而是给模型指定一个行为身份和工作风格,比如“你是一个怀疑型审计员,发现矛盾时一定要追问”。这种角色设定可以提前固化在系统提示词里,每次请求直接引用。
4.3 组合模板实现简单 Agent 行动循环
把提示词模板真正变成 Agent 循环,是我觉得最直观的一次前后对比。假设场景是:让模型判断用户的问题要不要调用工具查询,并且只有查询结果确认后才会回答。
先定义一套行动模板:
agent_prompt = """ 你是任务执行器。你现在拥有这些动作: - call_tool(tool_name, args):调用工具获取信息 - final_answer(answer):返回最终答案 当前任务:{task} 你已完成的步骤: {history} 请选择下一步动作并严格输出 JSON: {"action": "call_tool 或 final_answer", "action_input": "工具名或最终答案"} """循环体代码大概长这样:
def run_agent(task, max_steps=5): history = [] for step in range(max_steps): prompt = render_agent_prompt(task, history) response = ollama.chat( model='qwen2.5:7b', messages=[{'role': 'user', 'content': prompt}], format='json', ) action = parse_action(response['message']['content']) if action["action"] == "final_answer": return action["action_input"] # 模拟执行工具并回填结果 tool_result = execute_tool(action["action_input"]) history.append(f"第 {step + 1} 步调用结果:{tool_result}") return "已达最大步骤限制,无法完成预测"这个代码总共不到三十行,但已经是一个有“判断-行动-观察-循环”雏形的 Agent。关键点在于每一步的模型输出都会重新拼进提示词模板,模型看到的不是孤立的单轮对话,而是一张持续追加的执行日志。
这里值得提醒的是:循环不能无限跑,必须设定最大步数。我有一次调试时忘加这个限制,某模型因为提示词里出现了一个循环执行的指令,硬生生跑了十几分钟,把本地资源占满。后来我在循环逻辑里强制增加上限,并且在每轮输出里要求模型必须把当前步骤序号反馈回来,这样更便于定位是谁把逻辑带偏了。
5. 实操中踩过的坑与排查方法
5.1 常见问题速查表
这一路走下来遇到的坑不少,很多问题不是模型不行,而是使用方法不对。我把高频问题整理成了一张表,按图索骥会快很多。
| 现象 | 常见原因 | 定位方法 |
|---|---|---|
| Python 调用时连接超时 | Ollama 服务没启动或监听地址不对 | curl 访问 11434 端口,看返回是否正常 |
| 请求能通但结果特别慢 | 模型首次加载,或 CPU 推理 | ollama ps看加载状态;换小模型或量化版本 |
| 中文回复夹杂英文或乱码 | 选择的模型中文能力弱 | 换中文强化模型或加指令模型标签 |
| 输出 JSON 解析失败 | 模型没启 format 模式,或输出被代码块包裹 | 请求加 format 参数;写一个通用解析函数 |
| 连续多轮对话后显存爆掉 | 历史消息无限制累积,上下文窗口占满 | 实现历史裁剪和摘要,不要全量塞入 |
| 模型不遵守提示词里的格式要求 | 提示词中指令交代不清楚,或变量污染 | 把格式要求放到提示词末尾,并加示例 |
5.2 性能与显存调优
性能问题在不同硬件上差异很大。如果只是在自己的笔记本上做验证,我建议优先选择量化小模型,比如 3B 或 7B 的 q4 版本。一般规律是:模型参数量约等于合理显存占用的经验门槛,7B 模型量化后大概占 4 到 6 GB 显存,14B 模型基本要 10 GB 以上。没有独显时,CPU 也不是不能跑,但性能会明显下降,适合验证功能,不适合做并发服务。
上下文长度也要主动控制,不要无脑用模型默认的最大窗口。在请求参数里可以传入num_ctx,我通常会根据任务实际需要设置一个比默认值更小的窗口,比如 4096 或 8192。窗口越小显存占用越少,响应也越快,而且某些模型在长上下文下容易“神游”,限制反而能让它更专注。
ollama.chat( model='qwen2.5:7b', messages=[...], options={'num_ctx': 4096}, )如果服务端要支撑多个并发请求,可以调整OLLAMA_NUM_PARALLEL环境变量。但并发变高之前要先确认显存扛得住,一个并发请求占 5 GB,四个并发就得预留二十 GB,这账还是得算清楚。
5.3 模板与模型行为不一致
提示词模板写了很多约束,模型却经常“叛逆”。有的情况是模型没有足够强的指令遵循能力,有的情况是提示词里同时出现了多个互相矛盾的指令。我自己的经验是,提示词里的规则不要超过三条,而且越靠后的内容模型遵守得越好。比如要求“输出 JSON”但前面又写了大段角色氛围描述,模型就容易把精力放在扮演上,忘了格式要求。
另一个有用的小技巧是给模型一个非常具体的输出示例。
# 提示里给出“参考示例”比单纯写规则更管用 你应当按照以下格式输出: { "summary": "一句话总结", "risk": "高/中/低" }模板里带上示例不是哄模型,而是给它降低“不确定性”。模型本质是在做下一个 token 的预测,给它看到完整合法的输出样例,等于帮它划出了一条概率更高的路径。
如果反复调整模板还是不稳定,测试时我会单独跑一个提示词测试台,把同一个模板重复调用模型十次,统计格式成功率和结果波动幅度。这样比在完整 Agent 流程里靠肉眼判断模板好坏要可靠得多。
结尾:最后再分享一个小技巧
我在实际跑 Agent 时,最喜欢用的一条经验是:把提示词模板和模型调用彻底解耦。模板文件单独放在一个目录里,甚至可以用 JSON 格式管理多个版本的提示词,运行时再动态加载。这样每次模型输出的行为发生变化,我只需要改模板文件,不需要改主流程代码。本地模型的优势之一就是试错成本低,不把模板做成可热更新的形态,就浪费了这个优势。提示词模板这件事,看起来只是字符串处理,但它是 Agent 行为稳定性最直接的杠杆。愿意在这一步多花心思,比后面擦输出格式的屁股要高效得多。