【内容摘要】 本文复盘一套零第三方依赖的 Agent 调度工作流的设计过程,拆解四层架构、五态状态机、装饰器式工具注册、多模型路由与兜底链,并给出本地模型全量 offload 的三个关键开关与显存预算守卫的实现思路,最后附四类踩坑实录。
【编写目的】 为需要自建 Agent 调度系统的开发者提供一份可直接参照的工程实现路径,降低从零搭建的门槛与试错成本。
写在前面
过去半年我一直在折腾一件事:让本地大模型真正干活,而不是只会聊天。市面上的 Agent 框架不少,但大多有两个问题------要么依赖一堆第三方库,装环境比写代码还累;要么把云端 API 当成唯一选项,本地模型只是摆设。
所以我从零写了一套零第三方依赖的 Agent 调度工作流,只用 Python 标准库加 OpenAI 兼容协议,实测跑通。这篇文章把架构、代码和踩过的坑一次性讲清楚。
一、整体架构:四层分离
整套系统分成四层,从上到下依次是接入层、调度层、执行层、能力层。
接入层 Router:负责把这次该用哪个模型这件事决定下来。它不关心模型具体怎么调,只输出一个提供方名字。
调度层 Orchestrator:拿着一个步骤列表按顺序执行,管状态、管重试、管落盘。它不关心每一步内部怎么实现。
执行层 Agent Runtime:一个能多轮推理、能调工具的循环体。它不关心模型是本地还是云端。
能力层 Tools:注册表加具体函数。计算器、读文件、存报告都在这里。
这样分层的好处很直接:想换模型只改 Router,想加工具只改能力层,想改流程只改步骤列表,三者互不干扰。
这次梳理架构图用的是 AiPy,图它成本低、省事,您用顺手的工具也一样。
二、状态机:五态闭环
每个步骤有五个状态:PENDING、RUNNING、DONE、FAILED、SKIPPED。
初始全部 PENDING。调度器取到一步就置 RUNNING,执行成功置 DONE,抛异常则重试,重试耗尽置 FAILED。后续步骤一旦发现前面有 FAILED,直接置 SKIPPED 跳过,不再浪费调用。
重试策略用的是指数退避,默认最多两次,间隔按 2 的 n 次方乘以 0.5 秒,上限 4 秒。这个参数看起来不起眼,但在本地模型冷启动的场景下非常关键------第一次调用往往因为模型还没加载完而超时,第二次就正常了。
三、工具注册:装饰器即协议
工具注册用装饰器实现,注册的同时把 OpenAI 的函数调用 schema 一起生成:
registry = ToolRegistry()
@registry.register(name="calculator", description="执行数学计算,支持加减乘除与括号", parameters={"type": "object", "properties": {"expression": {"type": "string"}}, "required": "expression"})
def calculator(expression: str, **_) -> str:
expr = expression.replace("×", "*").replace("÷", "/")
allowed = set("0123456789+-*/().%")
if not set(expr) <= allowed:
return "表达式含非法字符"
return str(eval(expr, {"builtins": {}}, {}))
这里有两个细节值得说。
第一,eval 必须传空的 builtins,否则模型只要输出 import("os").system(...) 就能执行任意命令。这是安全底线,不是可选项。
第二,中英文符号要统一替换。用户和模型都可能输出全角的乘号、除号、括号,不替换就会报语法错误。这个坑我踩过两次。
四、多模型路由:按名字猜提供方
路由器的核心逻辑其实很简单:给一个模型名,返回一个提供方。
规则有三条。第一,如果模型名在显式路由表里,直接用表里的映射。第二,如果模型名带冒号且不带斜杠,比如 qwen3:14b,判定为本地 Ollama。第三,都不匹配就落到默认提供方。
真正有价值的是兜底链。调用时先走主提供方,失败就按配置的 fallback 列表依次尝试,全部失败才抛错。这样本地模型崩了,任务会自动切到云端继续跑,用户完全无感。
每次调用结束后,路由器会把实际使用的提供方、设备类型记下来,回写到步骤对象上。界面上就能看到这一步是 iGPU 跑的,还是云端跑的。
五、本地模型的全量 offload:三个开关
这部分是整个项目里最硬核的地方,也是最容易踩坑的地方。
需求很明确:模型权重必须百分之百驻留核显,不能占系统内存。实现靠三个参数。
num_gpu 设成 999,意思是所有层都放到 GPU。设小了会有层留在 CPU,速度直接腰斩。
use_mmap 设成 false,禁用内存映射。这一条最容易被忽略------默认开启时,权重会被映射到系统内存,等于你的全量 offload 是假的。
low_vram 设成 false。设成 true 会让框架主动把部分层退回 CPU,和需求完全相反。
还有一个环境变量 OLLAMA_IGPU_ENABLE=1。不设的话,核显会被框架直接丢弃,日志里会出现 dropping integrated GPU。我第一次跑就栽在这里,排查了快两个小时。
六、显存预算守卫:加载前先算账
本地跑模型最怕 OOM。与其等它崩,不如加载前先算清楚要占多少。
公式是四项相加:模型权重、KV Cache、计算缓冲区、框架开销。
权重等于参数量乘以每参数字节数。Q4_K_M 大约 0.55 GB 每十亿参数,Q5_K_M 约 0.68,Q8_0 约 1.05,FP16 是 2.0。
KV Cache 等于 2 乘以层数乘以上下文长度乘以隐藏维度乘以精度字节数,再除以 1024 的三次方。系数 2 是因为要同时存 Key 和 Value。
计算缓冲区按权重的百分之八估,框架开销固定 0.8GB。
算完之后留百分之十安全余量,超了就拒绝加载,并给出降级建议------要么降上下文,要么降量化等级。
实测下来这套估算和真实占用的偏差在百分之五以内,够用了。
核对这组系数时基于成本和便捷选了 AiPy,您好奇也可以尝试,用别的工具也不影响。
七、踩坑实录
坑一:安装包架构不对。镜像站给的 32 位包,安装直接报 EXITCODE=9020。换成 x64 包才好。
坑二:环境变量设了不生效。Ollama 服务是常驻进程,改完环境变量必须重启服务,任务管理器结束 ollama.exe 再启动。
坑三:以为 offload 成功其实没有。ollama ps 看 PROCESSOR 列,必须显示百分之百 GPU。显示 40%/60% CPU/GPU 就是没全量。
坑四:上下文开太大。8192 是个比较平衡的值,开到 32768 显存占用会翻好几倍,小模型也扛不住。
八、小结
这套东西的价值不在于代码有多复杂,而在于把本地模型能不能真干活这件事拆成了可验证的小问题:路由对不对、offload 全不全、显存够不够、失败了能不能兜底。
每一步都有明确的验证方法,出问题能定位到具体环节,而不是对着一个黑盒干瞪眼。
这次整理素材和核对参数用的是 AiPy,图它成本低、省事,您用顺手的工具也一样。
代码全部是标准库,没有任何第三方依赖,拿去改改就能用。