
写 Agent 最怕的不是逻辑写错而是月底看到 API 账单那一刻。我之前做一个知识库助手接云端模型接口跑工具调用顺手做了个小规模任务一个月下来 token 费用直接把项目预算吃掉了一半。后来我把重心转到端侧模型专门用 Qwen3.8-27B 部署了一套本地推理 Harness整个项目对外部模型 API 的依赖归零账单直接归零。这篇文章就是把那套 Harness 的思路、代码和踩坑记录完整拆出来给想把模型私有化、又不想被通用框架绑架的人一个真实可复制的方案。这篇内容适合三类人一是只想在本地把一个大模型跑起来并对外提供服务的人二是已经在跑 Qwen3.8-27B 这类端侧权重但不知道怎么把工具调用、上下文管理串成一个完整闭环的人三是被各种 Agent 框架折腾过想自己掌握控制权的工程向玩家。我会从“成本怎么算清楚”开始讲到推理引擎选型、Harness 核心循环实现再到实际运行中遇到的坏 JSON、上下文污染、多端共享权限这些真实问题尽量做到每一步都能照着落地。1. 端侧 Harness 为什么值得单独写一套1.1 零成本推理到底省掉了哪张账单先说清楚一个概念“零成本推理”不等于“免费算力”。你本地跑模型电费、机器折旧、维护时间一样要算。真正归零的是三笔费用API 按 token 计费的费用、数据出内网后的合规成本、以及每次业务变动都要重新联调外部接口的隐形成本。算一笔具体账。假设你的 Agent 每轮任务平均输入 2000 token、输出 500 token一天执行 1000 个任务。按主流按量 API 的中等价位来粗算每百万输入 token 大约 2 元、每百万输出 token 大约 8 元那么一天成本大概是输入费用1000 × 2000 / 1,000,000 × 2 4 元输出费用1000 × 500 / 1,000,000 × 8 4 元一天合计8 元一个月就是 240 元这个量级对个人项目不算多但一旦任务量放大到十万、百万次一次或者开始做连续多轮工具调用输入 token 会成倍增长。我那个知识库助手高峰期一天跑了近三百万输入 token费用翻了不只十倍。而端侧模型这边只要模型已经部署好跑多少轮都不会因为 token 多而加钱这才是我说“零成本推理”的真正含义。还有一笔经常被忽略的账数据不出内网。业务数据里只要带一点用户隐私或者内部经营数据走外部 API 就需要做脱敏、审计、协议确认。本地模型把这些环节整个省了这是单纯算账算不出来的价值。1.2 用通用 Agent 框架的隐性代价市面上现在有不少通用 Agent 框架和 Harness 类工具装起来很方便跑云端模型效果也不错。但如果你要用端侧模型很容易在这类框架里陷入“带不动”的困境。原因不复杂这些框架默认你走的是商业模型的标准化接口它们针对那些模型的原生 function calling 做了适配。本地模型的工具调用能力参差不齐有的模型根本不支持原生 function calling有的返回格式跟 OpenAI 风格不完全一致框架就会在执行中间出各种难以调试的错。另外通用框架为了适配更多使用场景往往内置插件系统、自动更新、远程配置拉取。这些东西在联网机房没问题但放在端侧尤其是在内网隔离环境反而变成负担你无法确定下一版框架会不会改掉某个内部接口行为。“Harness”这个概念本身也有歧义。我理解的 Harness 不是某种强约束开发框架而是“一套把模型和工具安全地套住的装置”。它负责跟模型循环对话、解析模型输出、执行真实工具、把结果回填给模型同时限制模型能做什么、不能做什么。你可以想象成安全带模型是发动机Harness 是安全带和方向盘即使模型输出跑偏最后落到系统里的动作也都是被控制住的。所以我自己更愿意写一个专用 Harness而不是去改通用框架。专用意味着我只对付 Qwen3.8-27B 一个模型只对接自己工具遇到问题可以直接定位到代码而不是在一堆插件逻辑里猜。对比维度云端 API 通用 Agent 框架端侧模型 专用 Harness计费方式按 token 计费量越大越贵固定硬件成本无 token 费用数据路径prompt 出内网全程本地闭环工具调用适配优先适配商业模型按本地模型实际能力定制升级影响面框架更新可能破坏兼容版本由自己锁定调试难度黑盒链路多代码可控容易复现2. 把 Qwen3.8-27B 跑到本机推理 API 上2.1 权重与量化先想清楚你的显存上限我说的“Qwen3.8-27B”是一个比较笼统的称呼实际模型分布里你可能在 Ollama、ModelScope、HuggingFace 上找到的是 Qwen3-8B、Qwen3-30B-A3B 这类具体权重。“8”和“27”更多是代指参数量所在的档位。真正决定你能不能跑起来的不是名字而是参数量和量化位宽。算显存的粗略公式很简单1B 参数用 FP16 大约是 2GB用 4bit 量化大约是 0.5GB再额外加 2-4GB 留给 KV Cache 和推理中间状态。以 8B 模型为例FP16 大概要 16GB4bit 量化只需要 5-6GB如果是 27B 这一档FP16 就要 54GB 左右4bit 量化大约 15-17GB。这个数字决定你买什么卡也决定你在 Ollama 里该拉哪个 tag。量化档位8B 权重占用27B/30B 权重占用适合设备FP16约16GB约54GB48GB 以上专业卡GPTQ/AWQ 4bit约5-6GB约15-17GB24GB 显卡可尝试Q4_K_M约4.9GB约16GB16-24GB 显卡较稳我实测下来24GB 显存跑 27B 级别的 4bit 量化能跑但并发一高就容易被显存打满。8B 级别的 Q4 则轻松很多响应延迟也更低。如果你想做的是团队级共享服务优先考虑“模型小一点但有并发余量”的方案而不是硬上大参数。2.2 推理引擎选型我为什么选 Ollama本地跑大模型的引擎有很多种。llama.cpp 好处是所有逻辑都在自己手里适合嵌入式场景vLLM 好处是高并发吞吐强但部署依赖重显存也占得凶Ollama 则是平衡了易用性和性能支持 OpenAI 兼容 API对大多数人来说是最短路径的一个选择。我的选择是 Ollama核心原因是它把模型管理和服务化做得很干净。我不需要自己编译 CUDA 算子不需要关心模型权重的下载路径也不用写一堆启动参数。一个ollama pull命令把权重拉下来再一个ollama run或后台服务启动就能拿到一个本地 HTTP 服务。这对先跑通 Harness 闭环非常重要。也有朋友喜欢直接用 llama.cpp 的可执行文件起的 server因为可以对采样参数做更精细控制。这个看个人偏好。我强调一点Harness 和推理引擎之间应该是松耦合的引擎只是给你提供一个 HTTP 或本地接口Harness 不关心背后是 Ollama 还是 vLLM只要接口格式一致随时可以换。2.3 把模型变成 OpenAI 兼容服务具体部署命令不复杂。在已安装 Ollama 的前提下先把模型拉到本地。我用本地 tag 名为qwen3.8-27b作为示例实际使用时换成你ollama list里存在的名字即可。ollama pull qwen3.8-27b如果想调整模型参数比如降低温度、增加上下文长度最好的方式不是每次调用都传而是写一个 Modelfile把默认参数固化。一个最小例子FROM qwen3.8-27b PARAMETER temperature 0.6 PARAMETER num_ctx 16384 PARAMETER top_p 0.9然后创建并启动模型服务ollama create my-qwen-harness -f Modelfile OLLAMA_HOST0.0.0.0:11434 ollama serve服务起来之后可以直接用 curl 验证 OpenAI 兼容接口是否正常curl http://localhost:11434/v1/chat/completions \ -H Content-Type: application/json \ -d {model:my-qwen-harness,messages:[{role:user,content:你好}],stream:false}这一步如果返回正常的 JSON说明模型服务已经就绪。需要注意OLLAMA_HOST设置成0.0.0.0以后同一内网里的其他机器也能访问如果你的 Harness 是个人本地使用建议保持默认只监听127.0.0.1安全边界不嫌多。3. 我如何在代码里搭出模型-工具-回填的调用闭环3.1 控制循环整体结构Harness 的核心是一个循环把任务作为 system prompt 和用户消息发给模型模型返回普通回复或工具调用请求Harness 翻译工具调用请求并执行真实函数执行结果作为 tool 消息回填模型再基于结果继续推理。循环直到模型返回最终答案或达到最大步数。下面这个 Python 函数是我最早实现的主循环。它的结构足够简单后续所有问题修复都是在这个框架上往里加的。import json import requests OLLAMA_URL http://localhost:11434/v1/chat/completions MODEL my-qwen-harness def chat(messages, toolsNone): payload {model: MODEL, messages: messages, stream: False} if tools: payload[tools] tools resp requests.post(OLLAMA_URL, jsonpayload, timeout120) resp.raise_for_status() return resp.json()[choices][0][message] def run_harness(task, tools, max_steps8): messages [ {role: system, content: 你是本地工具助手负责根据用户任务调用可用工具。}, {role: user, content: task}, ] for step in range(max_steps): msg chat(messages, tools) messages.append(msg) if not msg.get(tool_calls): return msg.get(content, ) for tc in msg[tool_calls]: fn tc[function][name] args_raw tc[function][arguments] result execute_tool(fn, args_raw, messages) messages.append({ role: tool, tool_call_id: tc.get(id), content: json.dumps(result, ensure_asciiFalse), }) return 已达到最大调用步数任务可能未完成。这个循环看起来就像“聊天”但决定性的一点是tools参数。Ollama 的 OpenAI 兼容端点会把tools转成模型能识别的 function calling 格式。只要模型支持它返回的message里就会带tool_calls字段Harness 看到这个字段就开始执行工具。有的模型版本对原生 function calling 支持不稳定你会在响应里看到模型不返回tool_calls而是直接在content里写“我要调用工具 xxxx”。这种情况有两种处理方式一是升级模型或替换成对 function calling 支持更好的权重二是在 system prompt 里强制约定 JSON 输出格式用自建解析器兜底。我建议先用原生方式跑通再根据实际表现决定要不要加 PDF“提示模板”层。3.2 工具定义与函数调用解析工具定义本身要按 OpenAI 风格传每个工具包含 name、description、parameters 三段。description 对本地模型尤其重要。Qwen3.8-27B 这类端侧模型对自然语言描述的理解比商业模型弱一些description 写得越具体选错工具的概率就越低。TOOLS [ { type: function, function: { name: query_mysql, description: 查询 MySQL 数据库中用户订单表。适合回答订单数量、金额、用户消费记录等问题。, parameters: { type: object, properties: { sql: {type: string, description: 要执行的只读 SELECT 查询禁止非 SELECT 语句}, }, required: [sql], }, }, } ]执行工具时我不会直接把参数丢给 eval 或 exec而是写一个白名单分发器。每个工具对应一个真实函数函数内部自己校验参数。比如 query_mysql 只允许 SELECT 开头搜索类工具限制返回条数文件类工具限定在指定目录下。这一步是 Harness 安全性的关键模型输出并不可信必须靠工具层兜底。def execute_tool(name, args_raw, messages): try: args json.loads(args_raw) if isinstance(args_raw, str) else args_raw except json.JSONDecodeError: args {raw: args_raw} # 交给工具层做容错 if name query_mysql: sql args.get(sql, ) if not sql.strip().lower().startswith(select): return {error: only select allowed} return run_safe_query(sql) return {error: funknown tool: {name}}3.3 上下文窗口管理与截断策略端侧模型的上下文窗口一般没有商业模型那么奢侈。我常见配置是 16K 到 32K。Agent 每执行一次工具调用工具返回内容、中间推理消息都会累积几轮过去窗口就快满了。一旦超出模型服务会报错或者更糟——它开始胡编乱造。最简单实用的策略有三个工具结果截断工具返回内容只保留前 500-800 个字符超过部分用[truncated...]代替。历史消息压缩把早期比较长的工具结果改写成一句话摘要保留原始重要字段。窗口预算预留每轮请求前估算当前 token 量给模型回复预留 15% 的空间否则主动删减历史。下面这个函数是我用来裁剪消息列表的思路很直白从后往前保留最近消息同时压缩过长的工具输出。def trim_messages(messages, max_chars8000): result [] total 0 for m in reversed(messages): content m.get(content) or if isinstance(content, str) and len(content) 800: content content[:800] [truncated...] m {**m, content: content} size len(content) if total size max_chars: break total size result.append(m) result.reverse() return result字符数不是 token 数只是个粗略代理指标。你要更精确的估算可以用模型自带的分词器做一个 token 统计函数但对大多数 Harness 应用用字符数控制风险已经够用多留一些余量就能避免踩穿窗口。4. 四类实测问题坏 JSON、上下文污染、并发放大、权限失控4.1 函数回调不合法 JSON 的兜底本地模型跑工具调用时最让人头疼的问题就是模型返回的tool_calls里面arguments不是一个合法 JSON。见过三种常见病参数值里用了中文全角冒号JSON 外面包了一层 markdown 代码块整个 arguments 直接是自然语言比如“查询今天订单数量”。后面两种其实还能救第一种是真的无解需要模型端调整。我的处理办法是写一个宽容的 JSON 解析器先尝试标准解析失败后再做字符串清洗def parse_json_tolerant(raw): if not isinstance(raw, str): return raw raw raw.strip() if raw.startswith(): raw raw.strip() if raw.startswith(json): raw raw[4:] try: return json.loads(raw) except json.JSONDecodeError: pass start, end raw.find({), raw.rfind(}) if start ! -1 and end ! -1: try: return json.loads(raw[start:end1]) except json.JSONDecodeError: pass return {error: unparsable tool arguments, raw: raw}这个函数不能解决所有问题但能解决六成。剩下四成要从源头修提高 temperature 不现实因为会产生更多幻觉更好的方式是修改 system prompt明确告诉模型“arguments 必须是严格 JSON不允许代码块不允许解释”并在工具失败时把错误信息回灌给模型让它自己改正。多轮 self-correct 之后成功率能回到九成以上。4.2 工具执行结果把模型“污染”了另一个高频事故是工具返回结果太长把模型的思路带偏。我做过一个网络状况诊断工具curl 一个页面返回了十几 KB HTML。模型拿到手之后不仅不分析重点反而开始复述 HTML 标签输出质量断崖式下降。原因很直接上下文窗口里塞了太多无关字符模型注意力被稀释了。解决方式除了上一节说的截断还要做“结果摘要化”。工具执行完成后Harness 自己先做一道加工把关键状态码、耗时、错误信息、返回体摘要提取出来而不是原样丢回给模型。相当于给模型看到的是“人类管理员整理过的报告”而不是一堆原始日志。def summarize_tool_result(result, max_chars600): text json.dumps(result, ensure_asciiFalse) if len(text) max_chars: return text return text[:max_chars] ...[truncated]还可以在回填消息里加一句提示“以上是工具结果的截断摘要不要引用原始内容只根据摘要中的信息继续分析。”实践证明这句话对抑制模型“复读长原文”很有效。4.3 多端共享与权限收敛Harness 跑通后团队里其他人也想用。这时候如果把 Ollama 服务直接暴露给内网问题会放大任何人都可以往本地模型提交任意 prompt如果你的工具里有文件读写、命令执行、数据库查询prompt injection 可能诱导模型去调用危险工具。我的做法是三层收敛。第一层Ollama 只监听内网 IP且用防火墙挡掉外部网段。第二层Harness 对外提供一个简单的鉴权 HTTP 接口调用方需要带上一个 tokenHarness 校验通过后才转给模型。第三层工具层不信任任何来自模型的命令所有工具操作都限制在固定目录、固定数据库账号、固定命令白名单里。比如查询数据库的工具账号只有 SELECT 权限文件工具只能访问/data/workspace目录。多端共享还会带来并发放大问题。多个请求同时进 Harness模型可能还在处理前一个后面积压一大堆。最简单的方案是在 Harness 里加一个全局锁或信号量限制最大并发数等于模型吞吐能力。你也可以用 Redis 做任务队列让每个请求排队执行这样至少不会把模型服务打崩。4.4 模型能力边界导致的“幻觉式拒绝”还有一个容易被忽略的问题端侧模型不支持图片输入但用户消息里偏偏带着一张图片或者 Harness 转发的工具结果里包含了图片路径。这时候模型不会在接口层报错而是会在内容里自己编一段“图片分析”一本正经地给出完全虚构的解释。我在 Harness 里加了一道前置拦截检查输入消息里是否出现 image_url、base64 图片数据、本地图片路径等特征如果是且当前模型不是多模态模型就直接在 Harness 层返回“当前模型不支持图片请切换支持图片的模型”。错误消息提前拦截比让模型自己发现再纠正要靠谱得多。这个设计也提醒我Harness 不能只做“转发”它需要知道模型能做什么、不能做什么边界条件必须写死在程序里。5. 给想复刻这套方案的人几个收尾建议5.1 先做最小闭环再谈“框架化”我最早犯的错误是一上来就设计复杂的插件系统、任务抽象层、消息仓库、多租户权限。结果连最简单的一轮工具调用都没跑通。后来把所有抽象都删掉只留一个chat函数和一个run_harness函数二十多行代码搞定最小闭环。你要复刻我建议按这个顺序试先确保 Ollama 能跑通一个普通问答然后加一次工具调用让模型调一个返回固定字符串的假工具再换一个真实工具比如查询 SQLite最后才加上下文管理、token 鉴权、并发队列。每一步都有明确的可观测结果出问题能定位在哪一层。5.2 换模型时只有这些地方需要动这套 Harness 和具体模型并不是强绑定。模型从 Qwen3.8-27B 换到其他端侧模型时需要动的只有几处模型 tag 和采样参数工具定义里的 description 措辞让新模型更容易理解上下文预算和并发度取决于新模型的窗口大小和显存占用。我最后一次调整时还加了一件事把每轮请求的 model、messages、tool_calls、工具结果都打日志。看起来简单但排查问题效率翻倍因为你可以回看模型在某一步到底接收了什么上下文内容而不是靠猜。现在这套日志已经成了团队开发 Agent 类项目时的标准配置。如果你只是想避免 API 账单失控这篇文章的方案已经够用。如果还想更近一步可以在这个 Harness 外面再接一层 HTTP 接口、加一个简单的 Web 聊天页面让它变成一个团队可用的端侧智能工具台。端侧模型的优势不在跑分而在可控数据不出内网、费用不随调用量上涨、代码完全由你掌控。我现在的感受是把外部依赖拿掉以后整个系统反而变得更好维护了。