
本地 AI 智能体这个事我前后折腾了大半年从最开始拿一台旧笔记本硬跑 7B 模型到后来在一台单卡机器上把一整套多模型协作的批量任务流水线跑通中间踩的坑比写过的代码还多。LM Studio 这类工具把本地加载模型这件事的门槛压得很低点几下就能对话但真要把本地 AI 智能体当成生产工具用起来尤其是多模型协调加批量任务这种组合光会点界面是不够的。你要面对的是显存怎么分、模型怎么换、上下文怎么截、并发开多少、失败了怎么续——这些问题没有一个能在图形界面里找到答案。这篇内容适合两类人一类是已经在本地跑过单模型对话想往智能体方向再走一步的另一类是手里有一批重复性文本任务想用本地模型批量处理、又不想把数据往外送的。我会把 LM Studio 当成一个模型服务器来看待围绕多模型协调和批量任务这两条主线把选型逻辑、接口细节、流水线设计、以及那些只有真跑起来才会暴露的问题一条条摊开讲。代码用 Python接口走 OpenAI 兼容格式你可以直接抄去改。1. 先把账算清楚本地智能体的显存预算与延迟底线动手之前我最想劝的一件事是先别急着写代码先拿张纸把账算清楚。本地智能体和云端 API 最大的区别不在模型智商而在资源是你自己的。你的显存是固定的你的显卡是独占的模型加载一次要几十秒这些东西一旦进入设计整个架构的思路都会跟调云端接口完全不一样。1.1 云端接口和本地推理的真实差距在哪调云端接口的时候我们习惯了一种很奢侈的用法一个请求里塞上万把字的历史反正按 token 计费贵是贵点但不会失败。本地推理完全不是这个逻辑。上下文长度是硬约束超了就是超了有的实现会静默截断前半段有的直接报错最坑的是截断之后模型还能正常输出只是它忘了你在开头定的规则。另一层差距是并发。云端接口你开 20 个线程同时打服务端自己扛本地单机单卡模型实例就是一个进程占着一块显存两个请求同时进来要么排队要么把 KV cache 撑爆。我最早那版脚本就是照着云端习惯写的ThreadPoolExecutor(max_workers8)跑第一条正常跑到第三条开始报显存不足日志里全是超时。所以本地智能体的第一条设计原则是把资源当成一等公民写进架构里而不是当作部署细节丢到最后再调。维度云端 API 习惯本地推理现实上下文尽量塞满越长越好按需裁剪宁短勿长并发多线程直接打串行或极低并发靠实例数堆延迟相对稳定百毫秒级首字首字几百毫秒到数秒受加载状态影响失败率低偶发限流显存、上下文、格式都可能触发失败成本模型按 token 付钱一次性硬件投入 电费 你的时间这张表我建议贴在显示器边上。后面所有的设计取舍基本都能从这张表推出来。1.2 用参数规模反推你的显存下限很多人问我这卡能跑多大模型这个问题没法一句话回答因为量化等级、上下文长度、是否多模型常驻三个变量一动答案就变了。我给一个粗略的估算思路你自己往里代。模型权重的显存占用约等于参数量 × 每参数字节数。FP16 是 2 字节8bit 量化约 1 字节4bit 量化约 0.5 到 0.6 字节实际略高于理论值因为有些层不量化。所以一个 7B 模型 4bit 量化权重占 3.5 到 4GB 左右14B 大约 8GB32B 大约 18 到 20GB。然后是 KV cache这部分经常被忽略。它的占用跟上下文长度、层数、隐藏维度成正比粗算可以用每 1000 token 占用几百 MB这个量级去估具体数值随模型差异很大同一个模型在 8K 和 32K 上下文下KV cache 能差出好几 GB。再加上运行时的一些固定开销大概留 1 到 2GB 余量。举一个我实际配过的例子单卡 24GB目标是跑一个 14B 级别的规划模型加一个 7B 级别的执行模型同时常驻。14B 4bit 权重约 8GB配 8K 上下文KV cache 加开销算 3GB合计 11GB7B 4bit 约 4GB配 4K 上下文算 1.5GB合计 5.5GB。两者相加 16.5GB卡上还剩 7GB 多看起来宽裕但实际上系统和其他进程还要吃一部分所以我最后把 7B 那一路的上下文压到了 4K 以内把并发锁死在 1。这个配置跑起来很稳连续跑了三千多条任务没出过 OOM。1.3 延迟不能只看 token/s新手看模型性能喜欢看每秒多少 token这个指标有用但不完整。真正影响批量任务总耗时的是单条任务的平均端到端耗时它由三部分组成首 token 延迟、生成耗时、以及被很多人忽略的模型切换或加载耗时。首 token 延迟跟你喂进去的上下文长度强相关。同一个模型200 token 的 prompt 首字可能 0.3 秒就出来了8000 token 的 prompt 可能要等两三秒——因为要把整个上下文过一遍前向。生成耗时才是 token/s 说了算的部分。而模型切换如果你的流程里要在规划模型和执行模型之间来回跳而显存又不够两个同时常驻那每次切换都要重新加载一次十几秒到一分钟不等批量任务里这个开销会被放大到无法接受。这就引出了后面章节的一个核心决策要么让所有参与协作的模型同时常驻要么把流程改造成分阶段批处理让同一个模型在一段时间内只干一件事。这两条路各有代价但都比一条任务内来回切模型要好得多。提示算显存的时候不要按理论最大值去卡留 20% 左右余量。本地推理的显存占用不是恒定的长 prompt 进来会临时冲高卡得太死就会出现平时都好好的偶尔崩一次这种最难查的故障。2. 把 LM Studio 变成一台模型服务器图形界面是用来试模型效果的真正做智能体开发你得把它当服务用。LM Studio 提供了本地服务能力会起一个 HTTP 服务对外暴露一套和主流接口格式兼容的端点。这一步做好了后面换模型、换工具、换框架客户端代码基本不用动。2.1 服务化之后客户端只认一个 base_url在应用里开启本地服务默认监听在本机的一个端口上常见是 1234端点路径和主流格式一致/v1/chat/completions、/v1/completions、/v1/models、/v1/embeddings。这意味着你可以直接用官方的 OpenAI 风格 SDK只把base_url指过来api_key随便填一个非空字符串就行。from openai import OpenAI client OpenAI( base_urlhttp://127.0.0.1:1234/v1, api_keylocal-no-auth, # 本地服务不校验但字段不能为空 timeout300.0, # 关键本地首字慢默认超时太短 ) resp client.chat.completions.create( modellocal-model-id, # 必须和服务端加载的模型标识一致 messages[ {role: system, content: 你是一个只输出 JSON 的抽取器。}, {role: user, content: 从下面这段文本里抽取公司名和金额。}, ], temperature0.1, )这段代码里有两个地方最容易出错。第一个是timeout。SDK 默认超时通常是 60 秒或者更短本地模型如果正在首次加载或者 prompt 特别长很可能超过这个时间客户端直接抛超时但服务端其实还在算。表现就是报错了但模型后来又把结果算出来了日志对不上。我一般直接给到 300 秒宁可等也不误判。第二个是model字段的取值。它必须和服务端当前加载的模型标识严格一致写错一个字都会返回 404 或者找不到模型的错误。稳妥的做法是启动时先打一次/v1/models把返回的列表打印出来确认你要用的那个 id 长什么样再写进配置。models client.models.list() for m in models.data: print(m.id)这个习惯帮我省过好几次时间。我有一次排查了半天为什么请求一直失败最后发现是模型标识里带了个版本后缀我照着别人的示例少写了一段。2.2 模型加载策略常驻、JIT 与 TTL 的取舍LM Studio 支持按需加载机制请求进来时如果模型没加载它会自动去加载用完之后经过一段空闲时间再卸载。这个机制对偶尔换模型试试的场景非常友好但对批量任务来说是个陷阱。原因很简单按需加载意味着第一个请求要等模型加载完这个时间可能是十几秒到一分钟。如果你的批量脚本里每处理几十条就要切换一次模型那总耗时会被加载开销吞掉一大半。更麻烦的是如果在加载过程中客户端超时重试会打进一堆重复请求把队列堵死。我在实际项目里的做法是批处理开始前把所有要用的模型手动预加载并且关掉或拉长空闲卸载时间。LM Studio 本身提供命令行工具和界面两种方式做预加载也可以用一个空的试探请求预热——发一条只有一个字的消息把模型顶起来。def warmup(client, model_id, retries30, interval5): 发一条极短请求等模型真正就绪 for i in range(retries): try: client.chat.completions.create( modelmodel_id, messages[{role: user, content: hi}], max_tokens1, timeout180.0, ) print(f[warmup] {model_id} ready) return True except Exception as e: print(f[warmup] {model_id} not ready ({i1}/{retries}): {e}) time.sleep(interval) raise RuntimeError(f{model_id} 预热失败)这个warmup函数几乎是我每个本地智能体脚本的标配。它解决的问题是让加载这件事发生在你控制的时刻而不是发生在批量循环的中间。什么时候该让多个模型同时常驻什么时候该用按需加载我给一个判断标准——看切换频率。如果一批任务里模型 A 和模型 B 的调用是交替出现的一条任务里先 A 后 B那必须同时常驻否则每条任务都要吃两次加载。如果是分段的前 500 条全用 A后 500 条全用 B那按需加载完全够用还能省显存。2.3 上下文长度这个旋钮拧错直接 OOM模型加载时一般可以设置上下文长度。这个值设小了长文档会被截断智能体的工作记忆不够用设大了KV cache 线性增长显存直接爆。而且有个隐含的坑有些模型配置文件里声明的最大上下文很长但实际训练时用的长度并没有那么长你硬设到最大值模型在后半段的注意力质量会明显下降表现是前面还记得后面就开始胡编。我的经验值是这样抽取类、分类类任务2K 到 4K 足够需要多轮工具调用的智能体8K 起步整篇长文档分析按文档长度给到 16K 或 32K但一定要同步确认显存扛得住。设完之后别忘了在请求层也做一次 token 预估和裁剪双保险。3. 多模型协调规划者、执行者、裁判各管一段单模型智能体最常见的失败模式是什么活都想让一个模型干。它既要理解任务、又要拆解步骤、又要调用工具、又要检查结果prompt 越写越长长到一定程度之后模型就开始丢指令。多模型协调的核心思路不是用更多模型显得厉害而是把互相冲突的能力要求拆开让每个模型只面对一类问题。3.1 为什么一个模型干不完所有活拆开有三个实打实的好处。第一是 prompt 变短了。规划者的 system prompt 只需要讲清楚输出什么样的任务分解结构执行者的 prompt 只需要讲清楚按给定步骤做这一件事。每个模型的上下文都很干净指令遵循度明显上升。我之前用一个 7B 模型同时做规划和执行光 system prompt 就写了两千多字结果它在第三步之后就忘了输出格式要求拆成两个模型之后两个 prompt 各三百字稳定得多。第二是模型能力可以错配。规划这件事对推理能力要求高对速度要求低用大一点的模型值得执行如果是格式转换、字段抽取这类活小模型完全够速度快好几倍。让 32B 去干 7B 能干的事纯属浪费让小模型去规划复杂流程它拆不明白。第三是失败隔离。执行者某一条输出格式不对重试就行不影响整体规划。单模型一体化的话格式一崩整条链路全废还得从头再来。3.2 角色契约用 JSON 把模型之间的对话固定下来多模型协调最容易翻车的地方是模型之间怎么传话。如果你让规划者输出一段自由文本再把它塞进执行者的 prompt那执行者每次收到的格式都不一样稳定性完全靠运气。我的做法是给每一对模型之间定义一个 JSON 契约。规划者的输出必须符合一个 schema执行者只接受这个 schema。这样中间层可以做校验、可以做重试、可以做缓存。PLAN_SCHEMA { type: object, properties: { steps: { type: array, items: { type: object, properties: { id: {type: integer}, action: {type: string, enum: [extract, classify, summarize]}, target_field: {type: string}, instruction: {type: string} }, required: [id, action, instruction] } } }, required: [steps] }把action做成枚举值是个小技巧可以极大降低下游的分支判断复杂度。模型不会给你发明一个analyze_deeply这种没法处理的动作名。请求时把 schema 传给服务端如果当前版本支持结构化输出参数就用参数不支持就在 prompt 里贴 schema 并加一句只输出 JSON不要任何解释然后在客户端做一层解析容错。import json, re def parse_json_loose(text: str) - dict: 从模型输出里尽力抠出 JSON text text.strip() # 去掉常见的 markdown 代码块包裹 text re.sub(r^(?:json)?\s*, , text) text re.sub(r\s*$, , text) try: return json.loads(text) except json.JSONDecodeError: pass # 退而求其次截取第一个 { 到最后一个 } start, end text.find({), text.rfind(}) if start ! -1 and end start: return json.loads(text[start:end1]) raise ValueError(f无法解析为 JSON: {text[:200]})这个函数看着粗糙但在实际跑批中救回来的比例相当可观。小模型在 JSON 前后加一句好的以下是结果是家常便饭硬性json.loads会直接失败而宽松解析能把这部分捞回来。3.3 两段式流水线先批量规划再批量执行这一节是我认为整篇文章里最值钱的部分。前面说过模型切换的代价很高那怎么在多模型协作的前提下避免频繁切换答案是把按任务循环改成按阶段循环。具体来说假设你要处理 500 条任务每条都需要先规划再执行。糟糕的做法是for task in tasks: # 500 次循环 plan planner(task) # 切到规划模型 result executor(plan) # 切到执行模型好的做法是plans [planner(t) for t in tasks] # 500 次规划模型不切换 results [executor(p) for p in plans] # 500 次执行模型不切换代码只差几行性能差出好几倍。原因就是模型加载和卸载的次数从 1000 次变成了 2 次。这个思路在本地推理场景下几乎是必须的在云端反而不重要——这也是为什么很多照着云端教程写的架构搬到本地就跑不动。这个改造有个前提规划阶段的输出必须能完整落盘执行阶段能独立读回来。也就是说你的中间结果必须可序列化、可持久化。这个要求顺带把断点续跑的问题也解决了一半。流水线形态模型切换次数中间结果可恢复适用场景按任务循环交错调用约 2N 次难任务量极小、交互式按阶段循环两段式2 次易批量任务首选三阶段规划/执行/校验3 次易质量要求高的场景3.4 裁判模型的价值与成本第三个角色是校验者也有人叫它裁判模型。它的工作是拿到执行者的输出判断这条结果是否合格不合格就退回重做或者标记出来。裁判模型能不能省取决于你的容错成本。如果输出只是给人看的参考稿人还会再扫一遍那裁判可以省。如果是自动进入下游流程的一个格式错误可能让整个管道崩掉那裁判很值。裁判模型选型上有个反直觉的点不要用和被校验模型同源的模型。同一个模型对自己的输出有天然的偏好它觉得这就是对的校准效果很差。用不同家族、不同规模的模型做交叉校验抓到问题的概率明显更高。我用一个 14B 做执行、一个 7B 做裁判抓格式问题很有效但如果两者互换7B 执行、14B 裁判虽然裁判更强但因为 14B 的判据更宽容反而漏掉一些小问题。这里面有点玄学建议用自己的数据实测。成本上要知道裁判这一步会让总调用次数增加 50%如果三段式是规划、执行、裁判各一次。对于本地推理这个成本主要是时间不是钱所以要算清楚你的时间预算。我现在的默认配置是格式类校验用纯代码做正则、schema 校验语义类校验才用裁判模型能把裁判调用量压掉一大半。4. 批量任务流水线从 CSV 到结果落盘前面的角色分工解决了谁来干这一节解决怎么干得快、干得稳、断了能接上。批量任务的工程量和单次调用完全不是一个量级很多在 demo 里不是问题的问题在 500 条、5000 条的规模下都会变成问题。4.1 任务队列与并发闸门先明确一个反直觉的结论本地单实例场景下并发数设 1 往往比设 4 更快。原因在于本地模型的推理是计算密集型的GPU 的算力就那么多两个请求同时进来各自分到一半算力总吞吐并不会提升反而因为 KV cache 占双份显存、调度开销增加整体变慢。更糟的是并发一高上下文一长显存占用峰值叠加很容易触发 OOM然后进程被系统杀掉前面跑的全白费。那想提速怎么办两条路。一是提高单条吞吐减少输出长度、缩短 prompt、用更小的模型二是多开服务实例绑不同端口每个实例加载不同模型或同样的模型靠实例数横向堆。第二条路要求显存足够而且要注意每个实例都要独立设上下文长度别一个实例设 32K 把卡吃光。我的队列结构大概是这样的import queue, threading, json, time from dataclasses import dataclass dataclass class Task: task_id: str payload: dict retry: int 0 def worker(task_q: queue.Queue, done_ids: set, out_fp, lock: threading.Lock): while True: task task_q.get() if task is None: # 毒丸用于优雅退出 task_q.task_done() break try: result process_one(task) # 真正的模型调用 with lock: out_fp.write(json.dumps( {task_id: task.task_id, ok: True, result: result}, ensure_asciiFalse) \n) out_fp.flush() # 关键每写一条就刷盘 except Exception as e: with lock: out_fp.write(json.dumps( {task_id: task.task_id, ok: False, error: str(e)}, ensure_asciiFalse) \n) out_fp.flush() finally: task_q.task_done()注意flush()那一行。批量任务最怕的就是跑到一半进程挂了缓冲区里的结果全丢。每写一条刷一次盘牺牲一点 IO 性能换来的是随时可以断、断了不丢。4.2 断点续跑别让第 900 条失败毁掉前面 899 条有了上面那个 JSONL 落盘续跑就很直接了启动时把结果文件读一遍收集所有task_id构造任务队列时跳过这些 id。def load_done(path): done set() if not os.path.exists(path): return done with open(path, encodingutf-8) as f: for line in f: line line.strip() if not line: continue try: rec json.loads(line) except json.JSONDecodeError: continue # 上次崩溃写了半行跳过 if rec.get(ok): done.add(rec[task_id]) return done这里有个细节只把ok: True的算作已完成失败的记录留着但允许重跑。同时要处理文件最后一行写了一半的情况——进程被杀的时候缓冲区可能写到一半这一行是坏的 JSON解析失败直接跳过就行不要因为一行坏数据让整个续跑逻辑崩掉。用 JSONL 而不是一个大 JSON 数组也是同样的道理。大数组要整体解析中途坏了整个文件都读不了JSONL 是逐行独立的坏一行不影响其他行。这个格式在日志类的批量场景里几乎是标准答案。4.3 错误分类与重试策略不是所有错误都值得重试。我的分类方式是这样错误类型典型表现处理策略上下文超限400 报错提示超过最大长度不重试裁剪输入后重新入队显存不足500 或连接被重置退避 30 秒后重试最多 2 次模型加载中连接拒绝 / 超时先 warmup再重试次数放宽JSON 解析失败客户端解析异常提高约束强度后重试最多 3 次内容质量不合格裁判模型判定换一种 prompt 策略重试最多 1 次重试要带退避而且每次重试最好稍微改一下参数比如把 temperature 调低一点或者把 prompt 里的格式约束加强。同样的输入、同样的参数、同样的随机种子跑两次大概率得到同样的错误这种重试纯属浪费时间。另外一定要设一个全局的重试上限防止某条毒数据把整个队列卡死。我的做法是把重试超过 N 次的 task 丢进一个死信队列文件主流程继续往下跑跑完再统一人工看。4.4 结果落盘与可追溯批量任务跑完之后你多半会遇到这条结果为什么是这个的追问。所以每条记录里除了最终结果我还会存三样东西用的哪个模型、prompt 的哈希、以及原始输入。record { task_id: task.task_id, ok: True, model: MODEL_ID, prompt_ver: v3, # prompt 版本号改了就升 input_hash: sha1(task.payload[text]), result: result, elapsed_ms: int(elapsed * 1000), ts: int(time.time()), }prompt_ver这个字段看着不起眼后面排查问题的时候价值极大。你会发现有些结果质量下降追溯回去是因为某次改了 prompt 但没记录导致新老数据混在一起没法比较。有版本号就能按版本切分统计。elapsed_ms用来算平均耗时和 P95帮你判断要不要换模型或者调并发。我一开始没存这个后来想优化性能的时候发现没法量化只能重新跑一遍白花了两个小时。5. 跑起来才暴露的三个坑上下文漂移、JSON 抖动、显存不回收前面讲的都是设计层面的事真跑起来之后问题往往出在你想不到的地方。这一节我把几个印象最深的故障完整复盘一遍包括我当时是怎么一步步定位的因为排查思路比结论更有复用价值。5.1 第 37 条开始返回空内容一次完整的排查链路现象一批 200 条的任务前 36 条都正常从第 37 条开始模型返回的内容是空字符串不报错HTTP 200finish_reason是stop。第一步我先确认是不是数据问题。把第 37 条的输入单独拿出来脱离批处理再跑一次——正常返回。排除了输入本身的问题。第二步怀疑是状态污染。因为我的执行器是个带记忆的智能体每条任务复用同一个对话历史只在末尾追加新内容。第 37 条单独跑正常但如果在跑完前 36 条之后再跑它就复现了。这就锁定了是历史累积导致的。第三步打印上下文长度。果然跑到第 36 条的时候累计的 messages 已经有 9000 多 token超过了我给模型设的 8K 上下文。服务端的处理方式是静默截断——把最前面的 system prompt 和最初的对话全砍了只留最后几条。模型看到的是几十条没有头没有尾的对话记录它不知道自己在干什么于是输出空。这个坑的本质是智能体的记忆和上下文窗口是两回事。记忆可以存在外部文件、数据库、向量库但每次送给模型的只能是窗口内那一段。把完整历史一股脑塞进去必然溢出。修法有三层。第一层每条任务独立不复用历史。批量任务里 90% 的场景根本不需要跨条记忆复用历史纯属自找麻烦。第二层确实需要多轮记忆时做滑动窗口加摘要保留最近 K 轮原文更早的用一次模型调用压缩成一段摘要。第三层请求前做 token 预估超了就主动裁剪别等服务端帮你截——它截的位置你控制不了。def trim_messages(messages, max_tokens6000, keep_systemTrue): 粗略按字符数裁剪中文约 1.5 字/token留足余量 def est(m): return len(m[content]) * 0.7 total sum(est(m) for m in messages) if total max_tokens: return messages system [m for m in messages if m[role] system] if keep_system else [] rest [m for m in messages if m[role] ! system] # 从最旧的开始丢保留最近的 while rest and sum(est(m) for m in system rest) max_tokens: rest.pop(0) return system rest字符数乘系数这个估算很粗糙但足够用。真要精确可以用对应的 tokenizer 算不过在批量场景里粗略估算加足够的安全余量性价比更高。5.2 结构化输出忽好忽坏第二个坑更隐蔽。用同一个 prompt、同一个 schema前面一百条输出都是规规矩矩的 JSON从某一条开始模型开始在前面加一句好的以下是抽取结果后面又加一句希望对你有帮助。这个问题的根源是采样随机性。温度不为零的时候模型每次选词都有随机性某一次它选了输出一句客套话这个分支后面就顺着这个分支走下去了。任务越多样、prompt 越宽松触发概率越高。我的处理是三层防御。第一层把 temperature 压到 0.1 以下抽取类任务甚至可以 0。第二层在 system prompt 里用强约束的措辞不是请输出 JSON而是只输出 JSON第一个字符必须是左花括号不要输出任何解释、前后缀或代码块标记。第三层客户端用宽松解析兜底。还有一个容易被忽略的点max_tokens设太小也会导致 JSON 截断。模型输出了一个完整的对象但正好在}之前被截断解析必然失败。这个错误看起来像模型不会输出 JSON实际上是参数设置问题。我在日志里加了一条判断——如果finish_reason length直接标记为输出被截断走重试并提高 max_tokens而不是走 JSON 修复逻辑这样能省下不少无效重试。5.3 并发一开就掉速这个我最开始完全没想明白串行跑 100 条用了 8 分钟并发 4 跑同样的 100 条用了 11 分钟越并发越慢。排查下来是两个原因叠加。第一GPU 算力被切分每个请求的速度都下降而总吞吐没有提升——这是预期内的。第二也是更致命的并发 4 的时候显存占用峰值上去了触发了 KV cache 的换入换出甚至有一次直接 OOM 导致服务重启重启之后又要重新加载模型这十几秒的加载时间被平摊到整个批次上把省下来的时间全吃了。最终的解法是单实例串行 多实例并行。开两个服务实例绑不同端口各加载一个模型两个 Python 进程分别打不同的端口。这样既是并行的又不会在单个实例内部争抢资源。代价是显存要够两个模型同时常驻如果显存不够那就老老实实串行接受这个速度。方案显存占用相对吞吐稳定性单实例并发 4高峰值叠加差可能更慢低单实例并发 1低基准高双实例各并发 1高双份权重约 1.8 倍高双实例共享同一模型中需确认是否共享权重取决于实现中这张表里的数字是我在自己机器上实测的量级你的结果会不一样但趋势应该是类似的本地推理优先保证单请求的稳定横向扩展靠实例不靠线程。6. 参数调优把不确定性压进可接受区间参数这事儿官方文档给的默认值是通用场景下的折中不是你这个场景的最优解。批量任务和聊天对话对参数的要求差别很大前者要的是极致的稳定和可复现后者要的是一定程度的多样性。6.1 temperature / top_p / repeat_penalty 的组合我的默认配置是这样的参数抽取/分类任务摘要/改写任务规划/推理任务temperature0.0 - 0.10.3 - 0.50.5 - 0.7top_p0.90.90.95repeat_penalty1.051.051.1max_tokens按字段数估算 ×2按原文长度 0.31024温度调到 0 的时候理论上输出是确定的但实际跑起来你会发现同一个输入两次调用偶尔还是有细微差异——这是浮点运算和批处理顺序带来的不是参数没生效。所以别指望绝对可复现能做到95% 一致就已经很方便调试了。repeat_penalty这个参数在本地模型上特别容易踩坑。设得太高比如 1.3模型会为了避免重复而刻意换词导致专有名词、字段名都被改掉抽取任务直接崩。设得太低长文本生成会出现车轱辘话。1.05 到 1.1 这个区间比较安全抽取类任务尽量往低走。还有一点同一批次内不要把参数改来改去。我吃过这个亏中途觉得输出太死板把温度从 0.1 调到 0.4结果同一批次里前半段和后半段的风格差异明显下游做统计的时候没法合并。参数要改就整批重跑或者加版本号区分。6.2 让模型少说话的三个手段批量任务里输出长度直接等于时间成本。模型多写一句废话500 条任务就多花好几分钟。压输出长度有三个手段效果依次递减。第一个是max_tokens。硬限制最直接。但要注意留够余量设太小会截断 JSON反而增加失败重试。第二个是 prompt 里的显式约束。不要只写简洁回答要写具体的结构要求比如输出三个字段每个字段不超过 20 字不要输出其他内容。具体的数字比抽象的形容词有效得多。第三个是 few-shot 示例。给一两个示例让模型照着格式走。示例的格式必须和你期望的完全一致包括有没有空格、有没有标点。模型对示例的模仿能力比对指令的遵循能力强得多这是小模型最实用的一招。注意few-shot 示例会计入 token占上下文。如果你为了压输出而加了三个长示例可能反而更慢。我的经验是最多两个示例每个控制在 100 字以内。6.3 一套可以直接抄的配置把前面所有东西串起来一个能直接跑的骨架大概长这样import json, time, queue, threading, hashlib from openai import OpenAI CFG { base_url: http://127.0.0.1:1234/v1, model_plan: your-planner-model-id, model_exec: your-executor-model-id, temperature_exec: 0.1, top_p: 0.9, repeat_penalty: 1.05, max_tokens_exec: 512, timeout: 300, } client OpenAI(base_urlCFG[base_url], api_keylocal, timeoutCFG[timeout]) def call(model, system, user, max_tokens512, temperature0.1, retries3): last_err None for i in range(retries): try: r client.chat.completions.create( modelmodel, messages[ {role: system, content: system}, {role: user, content: user}, ], temperaturetemperature, top_pCFG[top_p], max_tokensmax_tokens, ) choice r.choices[0] if choice.finish_reason length: max_tokens int(max_tokens * 1.5) # 被截断抬上限重试 continue return choice.message.content except Exception as e: last_err e time.sleep(min(2 ** i * 5, 30)) raise RuntimeError(fcall failed after {retries} retries: {last_err})这段代码里有几个我认为必须保留的细节finish_reason length的判断并自动抬高上限、指数退避但封顶 30 秒、以及失败之后抛出带上下文的异常而不是静默返回 None。静默失败是批量任务里最难查的问题宁可让程序崩出来。7. 我把这套东西跑顺之后的一些体会这套流水线我在几个不同的数据批上跑过规模从几百条到几千条机器从单卡 16G 到单卡 24G。最大的体会是本地智能体的瓶颈基本不在模型能力上而在工程约束上。一个 7B 模型在结构清晰的单项任务上表现可能比一个 32B 模型在混乱 prompt 下更可靠——因为前者永远不会超时、不会 OOM、不会因为上下文溢出而突然失忆。另一个体会是关于值不值得。本地跑批的时间成本是实打实的一条任务快则两秒慢则三十秒500 条就是几小时的等待。所以我现在会先花十分钟做一个判断这个任务的失败代价高不高如果输出要直接进下游系统本地跑 三重校验就值得如果只是给人做参考人工扫一眼的成本比搭管道还低那不如手动来几条。最后分享一个我觉得挺有用的小习惯给每个批次的运行日志打一个批次号并记录当时的模型配置、prompt 版本、参数快照。一个 JSON 文件就行几十行。等到两周后你发现某批结果有问题想复现的时候没有这个快照你就得靠回忆去猜当时用了什么配置。我因为这个习惯至少省过两次通宵重跑。