如果在本地把这几年开源的大模型挨个跑一遍,你会发现一件很有意思的事:把模型跑起来通常只需要一两条命令,真正难的是让它在真实任务里“靠谱地工作”。
就拿 Qwen3 系列里的端侧版本来说,8B、27B 这两档模型被大量开发者拉到自己笔记本和台式机上,目标是实现“零成本推理”。但部署完成之后,很多人很快发现一个尴尬的事实:模型能聊天、能写诗、能做简单问答,可让它“查一下系统时间”“算一个表达式”“在代码仓库里搜索某个函数”,它就变得非常被动,甚至直接输出一段格式错误的 JSON。
这里面的差距,不在模型本身,而在模型外面的那层工程封装——也就是最近社区里反复提到的Harness。从 DeepSeek Harness 到 Codex Harness,大家不约而同地意识到同一个问题:基座模型决定的是能力上限,Harness 决定的是能力能不能兑现。
这篇文章不会鼓吹“端侧模型已经可以全面取代云端 API”,也不会把 Harness 讲成一个故弄玄虚的框架。我会从实际开发视角,把 Harness 到底是什么、为什么端侧模型特别需要它、怎么用 Qwen3 系列 8B/27B 这类模型在本地搭起一套最小可用的推理链路,一步步讲清楚。读完你至少能回答一个问题:同样是本地模型,为什么有些项目能用起来,有些项目只能当聊天玩具。
1. 零成本推理的真实含义与前置条件
“零成本推理”是标题里最吸引人的词,也是最容易被误解的词。
如果你之前使用云端大模型 API,每一轮对话、每调用一次工具,账单都会增加。到了月底,钱主要花在两类地方:一是模型的 Token 费,二是把工具链路跑通之后的重复调用费。把模型部署到本地之后,单次推理的边际 API 费用确实可以趋近于零,这是“零成本推理”这个说法能成立的前提。
但注意,这不等于一分钱不花。完整成本其实包含四块:
- 硬件成本:显卡、内存、整机,或者一台已有 Mac 的折旧。
- 电力成本:8B 档位的模型跑起来还好,27B 档位跑满时功耗明显上升。
- 维护成本:模型文件下载、量化、推理服务重启、依赖升级。
- Harness 开发成本:这是最容易忽略的一块。模型部署完成后,后面接多少工具、上下文怎么管理、任务循环怎么终止,都需要花时间写代码。
所以更准确的判断是:端侧模型不是免掉了成本,而是把按调用次数计费,换成了前置的一次性投入与运维投入。
对个人开发者来说,如果手里有现成的 12GB 以上显存显卡,或者 16GB 以上统一内存的 Mac,把 8B 档位模型跑起来,边际成本确实低到可以忽略。但对企业来说,所谓“零成本”其实是把成本从“按 Token 付费”变成了“预先付治理成本”。你需要自己管理模型版本、推理服务、权限边界和监控告警。
那么,为什么会有人愿意花这些成本回到端侧?核心不是“免费”,而是三点:
- 数据不出域。业务数据、代码仓库、隐私资料不会因为调用云端 API 而离开本机。
- 长链路可控。调云 API 时,如果某个 Agent 循环失控,后端模型不会立刻恢复;本地部署则可以通过 Harness 直接中断和回滚。
- 延迟更稳定。相对于公网 API,本地推理在稳定网络环境下的一致性更好,适合对响应时间敏感的工具链。
所以,后续章节里所有“零成本”表述,都应该在“省掉 API 费用”这个前提下理解。真正的技术重点,不是模型怎样被部署起来,而是模型部署好之后,Harness 怎么让它在任务循环里稳定运行。
2. Harness 在端侧模型中的核心作用
2.1 什么是 Harness
Harness 英文原意是“马具、缰绳”。在 AI 工程里,你可以把它理解成模型与外部世界之间的适配与执行层。
大模型本质上是一个文本生成引擎。它接收一段文本输入,根据训练得到的概率分布,生成下一段文本。这句话听起来朴素,但揭示了关键问题:模型不会主动调用外部函数,不会自己去文件系统里找文件,也不会在代码仓库里执行搜索。它只会“生成一段文字”,这段文字可能是回答问题,也可能是一段 JSON,表示它想调用某个工具。
要让模型真正做事,你需要一个外部系统完成这样的循环:
- 把工具描述、可用函数、系统限制一起发给模型。
- 模型根据任务判断是否调用工具。
- 如果模型输出了工具调用意图,Harness 解析并执行对应函数。
- 执行结果再塞回上下文,让模型基于结果继续推理。
- 重复直到模型给出最终答案或达到最大轮数。
这个外部系统,就是 Harness。它不负责模型的权重,不负责训练,只负责让模型“接入真实世界”。
2.2 传统调用方式与 Harness 的区别
很多开发者第一次接触本地模型时,用的可能是最简单的“单轮问答”:
from openai import OpenAI client = OpenAI(base_url="http://localhost:8000/v1", api_key="EMPTY") resp = client.chat.completions.create( model="qwen3-8b", messages=[{"role": "user", "content": "今天几号?"}], ) print(resp.choices[0].message.content)这种写法能工作,但你会发现两个问题:第一,模型对“今天几号”这类需要实时信息的回答,只能靠训练数据里的日期猜测;第二,如果任务比较复杂,比如“先查当前时间,再计算 123 乘以 456,然后告诉我结果”,单轮对话根本无从下手,因为模型不会自己把每一步衔接起来。
Harness 改变了这个流程。传统 Prompt 调用相当于你把一份工具说明贴在对话前面,让模型碰运气;Harness 则通过 function calling 协议,给模型提供结构化工具定义,模型输出结构化的调用请求,Harness 解析后执行,再把结果回填。
两者的对比可以用一张表概括:
| 对比项 | 普通 Prompt 调用 | Harness 封装 |
|---|---|---|
| 工具接入 | 把工具说明写进提示词,靠模型自行格式化输出 | 走 function calling 协议,结构化声明工具 |
| 结果回传 | 需要自己写正则或 JSON 解析 | 由 Harness 完成 tool_call 回填 |
| 多轮任务 | 每轮手动拼上下文,容易越拼越长 | 循环自动维护消息列表 |
| 稳定性 | 依赖模型指令跟随能力,格式稍变就崩 | 有解析兜底、重试、最大轮数控制 |
| 适用场景 | 简单问答、文本生成 | 工具调用、代码执行、Agent 任务 |
从社区最近讨论的 DeepSeek Harness、Codex Harness 可以看到一个趋势:很多人开始承认,Agent 的效果上限不再单纯由基座模型决定,而是由 Harness 的质量决定。一个开源模型配上完整工具链、健壮的上下文管理和合理的终止策略,能完成的任务复杂度,是纯 Prompt 调用无法比拟的。这也是为什么端侧模型不再等于“玩具”的关键转折点。
3. Qwen3 8B/27B 的端侧选型思路
3.1 为什么选 Qwen3 系列
标题里的 Qwen3.8-27B,可以理解为 Qwen3 系列中适合端侧部署的 8B 档位和 27B 档位模型。这类开源模型之所以流行,有很实际的原因:
- 指令跟随能力强,针对 Agent 场景做了工具调用相关优化。
- 开源权重可以自由部署,适配 Ollama、vLLM、llama.cpp 等主流推理框架。
- 参数规模有梯度,8B 档位适合轻量任务,27B 档位适合对推理质量要求更高的场景。
- 社区资料多,遇到部署问题比较容易找到解决方案。
当然,具体到某个版本号,请以模型官方发布为准。本文的推理链路设计对同量级的开源模型同样适用。
3.2 端侧模型选择原则
选型不是越大越好。真正要考虑的是四件事:显存装得下、上下文长度够用、工具调用稳定、推理速度可接受。
先看显存。以常见的 Q4 量化为参考,8B 档位模型的权重文件通常在 5GB 左右,12GB 显存的显卡已经比较从容;27B 档位模型量化后通常不超过 20GB,24GB 显存或者 Mac 的 32GB 统一内存在体验上会更稳妥。不同量化策略差距很大,实际体积请以你下载的模型文件为准。
再看硬件匹配。下面是一个粗略选型表:
| 档位 | 量化后权重体积(粗略估算) | 推荐硬件 | 适合任务 |
|---|---|---|---|
| 8B 档 | 5GB 左右 | 12GB 以上显存显卡,或 16GB 统一内存 Mac | 工具调用、文本摘要、轻量 Agent |
| 27B 档 | 15-20GB | 24GB 以上显存显卡,或 32GB 统一内存 Mac | 复杂推理、代码补全、多轮任务 |
第三个是工具调用稳定性。8B 模型在简单工具链上表现尚可,但工具数量一多,或者在复杂上下文里,更容易出现“该调用工具却直接生成答案”“格式错误”等问题。27B 档位的稳定性通常更好,不过推理速度也更慢。所以实际项目里,可以考虑“简单任务走 8B,复杂任务走 27B”的双模型路由,而不是只靠一个模型打天下。
第四个是上下文长度。Agent 任务里,每一轮工具调用都会往上下文追加内容,如果上下文窗口太小,几轮循环后被挤爆,Harness 就会进入不稳定状态。部署时建议把 max_model_len 设置成模型支持范围里的一个中间值,比如 8192 或 16384,而不是直接拉满。
4. 本地推理环境搭建:Ollama 与 vLLM 两种路线
要跑 Harness,第一步是先把本地推理服务跑起来。这里有两种主流路线,对端侧开发者都比较常见。
4.1 路线一:Ollama,最适合快速验证
Ollama 适合第一次接触本地模型的开发者。安装完成后,拉取模型并启动服务即可。
# 查看本地已有模型 ollama list # 拉取 Qwen3 8B 档位模型,具体标签以 Ollama 模型库为准 ollama pull qwen3:8b # 启动交互式对话 ollama run qwen3:8bOllama 较新版本默认会提供一个 OpenAI 兼容端点:http://localhost:11434/v1。也就是说,你不需要额外写一套调用代码,直接用 OpenAI SDK 指向这个地址就行。如果你用的是 27B 档位,Ollama 同样能加载,只是模型体积更大,启动时间更长,显存或内存压力也更大。
4.2 路线二:vLLM,适合 GPU 富余和并发场景
如果手里的显卡显存比较充足,或者你希望后续把 Harness 服务共享给团队使用,可以用 vLLM 启动一个 OpenAI 兼容的推理服务。
较新版本 vLLM 推荐使用vllm serve命令,旧版本对应python -m vllm.entrypoints.openai.api_server,二选一即可。下面是等价的一种启动方式:
vllm serve /data/models/Qwen3-8B-Instruct \ --served-model-name qwen3-8b \ --max-model-len 8192 \ --gpu-memory-utilization 0.85启动后,服务默认监听在http://localhost:8000,OpenAI 兼容接口路径是http://localhost:8000/v1。
4.3 验证推理服务是否就绪
无论用哪种路线,启动之后都要先确认服务健康。最简单的验证方式是通过/v1/models接口查一下模型列表:
curl http://localhost:8000/v1/models如果能看到模型返回,说明服务已经起来。接下来可以发一个最小对话请求:
curl http://localhost:8000/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ "model": "qwen3-8b", "messages": [{"role": "user", "content": "1+1等于几?"}] }'到这里,你已经有了一套可用的本地推理后端。下面要做的,是把 Harness 接上去。
5. 手写一个最小可运行的端侧 Harness
这一节,我们从一个最小示例出发,把 Harness 的核心模块完整跑通。示例会包含三部分:本地模型客户端、两个工具函数、一个任务循环控制器。
5.1 项目结构与依赖
假设项目目录结构如下:
endpoint-harness/ ├── harness_demo.py └── requirements.txt依赖只需要一个openaiPython 库,因为它兼容 Ollama 和 vLLM 提供的接口。
# requirements.txt openai>=1.0安装依赖:
pip install -r requirements.txt5.2 完整代码
下面是harness_demo.py的完整实现。代码设计很克制,但该有的模块都有:模型接入层、工具注册、上下文管理、任务循环、异常兜底。
import datetime import json from openai import OpenAI def get_current_time() -> str: """获取当前本地系统时间""" return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") def calculate(expression: str) -> str: """ 计算数学表达式。 注意:这里为了演示简洁使用了 eval,仅适合在本地可信环境中运行。 生产环境请用 ast.parse 或白名单方案替代,避免任意代码执行风险。 """ return str(eval(expression, {"__builtins__": {}}, {})) class LocalLLM: """本地模型客户端,兼容 Ollama 与 vLLM 的 OpenAI 兼容端点。""" def __init__(self, base_url: str = "http://localhost:8000/v1", model: str = "qwen3-8b", api_key: str = "EMPTY"): self.client = OpenAI(base_url=base_url, api_key=api_key) self.model = model def chat(self, messages, tools=None, max_tokens=512): params = { "model": self.model, "messages": messages, "max_tokens": max_tokens, } if tools: params["tools"] = tools params["tool_choice"] = "auto" resp = self.client.chat.completions.create(**params) return resp.choices[0].message class Harness: """最小任务循环:模型决定是否调用工具,Harness 负责执行并回填结果。""" def __init__(self, llm: LocalLLM): self.llm = llm self.system_prompt = ( "你是一个运行在本地设备上的智能助手。" "当需要获取实时信息或计算结果时,请优先使用工具,不要自己猜测。" ) self.messages = [ {"role": "system", "content": self.system_prompt}, ] self.tools = [ { "type": "function", "function": { "name": "get_current_time", "description": "获取当前本地系统时间", "parameters": { "type": "object", "properties": {}, }, }, }, { "type": "function", "function": { "name": "calculate", "description": "计算数学表达式", "parameters": { "type": "object", "properties": { "expression": { "type": "string", "description": "要计算的数学表达式", } }, "required": ["expression"], }, }, }, ] self.functions = { "get_current_time": get_current_time, "calculate": calculate, } def run(self, user_input: str, max_turns: int = 5): self.messages.append({"role": "user", "content": user_input}) for turn in range(max_turns): message = self.llm.chat(self.messages, tools=self.tools) print(f"[Turn {turn + 1}] 模型输出: {message.content or '(调用了工具)'}") if not message.tool_calls: print(f"[Final] {message.content}") return message.content # 把模型的工具调用意图追加回上下文 self.messages.append({ "role": "assistant", "content": message.content or "", "tool_calls": [ { "id": tc.id, "type": "function", "function": { "name": tc.function.name, "arguments": tc.function.arguments, }, } for tc in message.tool_calls ], }) for tc in message.tool_calls: name = tc.function.name try: args = json.loads(tc.function.arguments or "{}") print(f"[Tool] {name}({args})") result = self.functions[name](**args) except json.JSONDecodeError: print(f"[WARN] 参数解析失败: {tc.function.arguments}") result = "工具参数不是合法 JSON,请重新调用。" except Exception as e: print(f"[WARN] 工具执行失败: {e}") result = f"工具执行失败: {e}" print(f"[Tool Result] {result}") self.messages.append({ "role": "tool", "tool_call_id": tc.id, "content": str(result), }) print("[Reached max_turns]") return None if __name__ == "__main__": # 如果使用 vLLM:默认 http://localhost:8000/v1 llm = LocalLLM() # 如果使用 Ollama:取消下面这行注释,并替换模型名为你实际拉取的标签 # llm = LocalLLM(base_url="http://localhost:11434/v1", model="qwen3:8b") harness = Harness(llm) harness.run("现在几点了?顺便帮我计算 123 * 456 的结果。")5.3 关键逻辑说明
LocalLLM类负责统一模型调用。它把base_url做成参数,这样从 vLLM 切换到 Ollama,只需要换地址和模型名,业务代码完全不用动。
Harness类里最核心的是run方法。它维护一个messages列表,每一轮做四件事:调用模型、判断是否产生工具调用、执行工具、把工具结果回填。最大轮数max_turns是一个必要的保险丝,没有它,模型一旦进入“不断调用工具但迟迟不收敛”的死循环,整个进程就会被卡住。
工具函数的eval是一个明显的安全示例点。演示代码里使用它是为了让你能跑通流程,但在生产环境中,直接eval用户输入或模型生成的表达式非常危险。实际项目可以把工具换成调用系统 API、读写白名单文件、执行测试用例等更受控的动作。
这里真正容易踩坑的地方是:很多开发者以为把tools参数传进去,模型就一定会使用工具。实际上这取决于推理后端是否支持 function calling,也取决于模型本身对工具协议的理解。如果模型总是忽略tools,直接给最终答案,优先检查推理后端版本和模型格式是否匹配。
6. 运行验证:如何判断 Harness 真的在工作
6.1 运行命令
确保本地推理服务已经启动,然后执行:
python harness_demo.py6.2 预期输出
如果 Harness 工作正常,你应该能看到类似下面这样的流程:
[Turn 1] 模型输出: (调用了工具) [Tool] get_current_time({}) [Tool Result] 2025-01-15 10:30:00 [Turn 2] 模型输出: (调用了工具) [Tool] calculate({'expression': '123*456'}) [Tool Result] 56088 [Turn 3] 模型输出: 当前时间是 2025-01-15 10:30:00,123 * 456 的结果是 56088。 [Final] 当前时间是 2025-01-15 10:30:00,123 * 456 的结果是 56088。这里有两个判断标准:
- 模型确实发出了
[Tool]调用,说明 function calling 链路是通的。 - `[Tool Result