我原本只是想在 Mac mini 上跑 Gemma,却给自己做了一个本地版“Codex”

45 阅读13分钟

今天,我只是觉得 Claude 和 Codex 的使用成本有点高,想在一台 16GB 的 M4 Mac mini 上把 Gemma4 和 Qwen 这些开源大模型跑起来,看看本地写项目代码到底能不能顶上一部分。大模型真跑起来以后,我又顺着这个想法往前多走了一步:既然单独 ollama run 已经能用,那能不能再给自己包一层更像 Codex 的本地终端工作流?于是就有了 Marcodex,我把 Ollama、项目索引、模型分工、patch 草稿、安全拦截和项目记忆串到了一起。

01

这件事最开始,其实没那么“产品化”。

我今天会突然去折腾本地开源大模型,一个很现实的原因是:Claude 和 Codex 虽然很好用,但费用确实不便宜。

如果只是偶尔问几句,这个成本可能还没那么明显,但如果真的把它们当成日常项目开发里的高频助手,一轮一轮地分析代码、改方案、看文档、修 bug,这个成本就会开始显著上升。

所以当我今天看到 Google 开源大模型 gemma4 发布的时候,第一反应不是“我要追个热点”,而是有点想认真试一下:现在能不能直接在我自己的 Mac 上跑开源大模型,看看它到底能不能替我接住一部分项目开发工作?

我公司的苹果电脑也不是什么顶配,就是一台 2024 款 Mac mini,M4 芯片,16GB 统一内存。

所以第一个问题很朴素:这台机器到底能不能在本地跑一个足够好用的大模型,而且不是跑个 demo 就结束,而是真的能在项目开发里帮我分担一部分 Claude / Codex 原来在做的事?

后来真的装起来之后,我发现答案是“能”,但事情马上就拐到了另一个方向。

因为手动 ollama run xxx 虽然能对话,但由于我习惯了codex cli、claude code,所以我就想试试看能不能做到类似的工作方式。

02

我先把本机模型这件事跑了一遍。

最后这台 Mac 上真正留住的,是这三个模型:


gemma4:e4b

modelscope.cn/Qwen/Qwen2.5-Coder-14B-Instruct-GGUF:latest

gemma3:12b-it-qat

它们的分工也很直接:

  • Qwen2.5-Coder-14B 主要负责代码解释、局部修改方案、生成小范围 diff。

  • Gemma3 12B 主要负责一般文档问答、图片理解、录屏抽帧后的辅助分析。

  • Gemma4 E4B 现在主要被我放在 fast-doc 这个位置,拿来做更快的文档扫读和短总结。

这里面还有一个小插曲。

我一开始也装过 Qwen3-VL-8B,本来是想让它专门看截图和录屏帧图,模型本身确实能装上,ollama show 也能看到 vision 能力,但只要我通过 Ollama HTTP API 真往里传图片,它就会直接报 model runner has unexpectedly stopped

这类问题很适合拿出来说一句实话:本地开源模型这套东西,很多时候不是“能不能下下来”这么简单,而是“下载下来了以后,真调起来到底稳不稳”。

所以后来我干脆把 Qwen3-VL 从本机删掉了,图片和视频分析先统一回到 Gemma3。这个决定不酷,但它更像一个真的要把工具拿去干活的人会做的选择。

03

模型能跑起来以后,我很快就发现,直接在终端里和模型聊天,还是不够。

比如我在一个老 iOS 项目里改代码,真正麻烦的从来不是“让大模型回答一个问题”,而是这些事:

  • 它不知道这个项目的目录结构和关键模块在哪里。

  • 它不知道哪些核心流程已经跑通,不能让它顺手重写。

  • 它不知道我这轮只想改哪个文件,不想让它把一堆旁边文件也“顺手优化”了。

  • 它不知道我默认不让它替我 git commitgit push

  • 它也记不住我这个项目里那些长期协作规则,每一轮都要重新交代。

这时候我脑子里冒出来的就不是“再找一个更大的模型”,而是另一个念头:

能不能在 Ollama 上面自己包一层终端工具,让本地模型按我的项目上下文、我的修改边界、我的使用习惯来工作?

这就是 Marcodex 这个想法的起点。

说得再直白一点,我不是突然想“造一个新玩具”,而是想看看:有没有可能在成本更可控的前提下,把一部分原来交给 Claude / Codex 的项目开发流程,搬到本地开源模型上来。

我当时给它定的目标也没那么大,不是“复刻一个完整 Codex”,毕竟受到电脑性能的限制,我下载下来的开源大模型的能力,肯定是比不过gpt5.4、claude opus4.6这些顶级大模型,

所以,我先做了一个对我自己真的有用的本地版本:


Marcodex = 全局 CLI + 项目索引 + 上下文装配 + 模型路由 + patch 草稿 + 安全门控 + 项目记忆

而且有几个边界,我一开始就写死了。

第一,它不会在我一打开终端时自动接管 shell,只有我自己输入 marcodex ... 的时候才启动。

第二,它不负责代码提交流程,只做本地开发和问题修复辅助,不碰 git commit/push

第三,第一版不追求“模型自己全自动改完整个项目”,而是坚持“先分析、再出 patch、我确认后再落文件”。

这几个边界,后来反而成了这套东西最重要的部分。

04

架构设计这一步,我没有一上来就堆很多“智能体”概念,而是先把这件事拆成几个很具体的模块。

最后 Marcodex 大概长这样:


marcodex CLI

-> WorkspaceState / MemoryStore / SessionStore

-> ProjectIndexer

-> ContextBuilder

-> ModelRouter

-> OllamaClient

-> PatchManager / SafetyGuard

-> VideoFrameExtractor

这几个模块各自干的事,其实都很“土”,但拼起来就够用了。

ProjectIndexer 负责 /init

它不会让模型自己去乱翻整个仓库,而是先由脚本做确定性的扫描:顶层目录有哪些、AGENTS.mddocs/memory/ 这些规则文档在不在、主要语言是什么、当前 git 分支和脏文件有哪些。然后再把这份结构化结果交给文档模型,让它生成一份人能看的 project-index.md

这个顺序很重要。

因为我不想把“代码仓库遍历”这种事交给模型猜,我只想让模型做归纳。

ContextBuilder 负责 /ask/patch 之前的上下文拼装。

我最后给它定的顺序是:先放项目长期规则,再放项目结构摘要,再放这次我显式点名的文件内容,再补当前工作区只读状态,最后才放我的问题。

代码里这个思路大概是这样:


sections = [

prompt_template.strip(),

"",

"## 项目硬规则 / 用户偏好",

self._read_optional_text(self.memory_path, fallback="(当前项目尚未初始化 memory)"),

]

  


if include_project_index:

sections.extend([

"",

"## 项目结构摘要",

self._read_optional_text(self.project_index_path, fallback="(当前项目尚未生成 project-index.md)"),

])

这一步做完之后,我对一件事的感受特别明显:本地模型能不能帮上忙,很多时候不只看模型参数量,还看你喂给它的上下文是不是按正确顺序摆好了。

ModelRouter 负责决定这一轮到底叫哪个模型。

我没有再请一个模型来“判断该找谁”,第一版直接写规则路由:有图片或视频就走视觉模型,/patch 就走代码模型,/init 和文档类任务走文档模型,fast-doc 优先用 Gemma4,不可用就回到 Gemma3。


if role == "vision" or has_image:

return RouteResult(model=self._config_text("vision_model"))

  


if command == "/patch":

return RouteResult(model=self._config_text("code_model"))

  


if role == "fast-doc":

fast_doc_model = self._config_text("fast_doc_model")

doc_model = self._config_text("doc_model")

if fast_doc_model not in self.available_models and doc_model:

return RouteResult(model=doc_model, fallback_message=...)

return RouteResult(model=fast_doc_model)

这套分工规则没有什么神秘的地方,但对一台 16GB 的机器来说,它比“多个大模型一起常驻、互相商量”现实得多。

05

真正让我觉得这工具开始“像个能干活的东西”的,其实是 /patch/apply 这条线。

因为只做 /ask,本质上还是一个带项目上下文的聊天壳。

但一旦模型要开始改代码,问题就变了:我怎么保证它只改我允许改的文件,怎么保证它别碰 .git/Pods/ 这些地方,怎么保证它吐出来的 diff 真能安全落下去?

所以我没有让模型直接写文件,而是做成了这个流程:


marcodex /patch --files path/to/file "只改这个文件,修复这个问题"

marcodex /apply <patch-id>

中间那层 PatchManager + SafetyGuard,我刻意做得比“能跑就行”更重一点。

/patch 必须显式传 --files,不然直接拒绝,patch 里如果出现不在白名单里的文件,拒绝。

如果路径指向 .gitPods.marcodex、绝对路径、.. 路径逃逸,拒绝。

如果单次 diff 超过 5 个文件或者 800 行,也拒绝,让我先把范围收小。

代码里最核心的判断,大概长这样:


if safe_relative_path not in allowed_files:

raise PatchSafetyError(f"patch 越界修改未授权文件:{safe_relative_path}")

  


if normalized_path.parts and normalized_path.parts[0] in BLOCKED_TOP_LEVEL_DIRS:

raise PatchSafetyError(f"禁止修改高风险目录:{relative_path}")

还有一个我后来补上的细节,我觉得很值得讲。

模型生成 patch 的那一刻,我会把目标文件当时的 sha256 摘要记下来,真正 /apply 之前,再把当前文件摘要算一遍,如果这个文件在 patch 生成之后已经被我手动改过,就直接拒绝应用。

这个机制不复杂,但它解决的是一个很真实的问题:风险不只是“模型会不会改错”,还有一种更烦人的情况是,模型给的是旧版本 diff,但我这边文件已经先改过了。

另外,本地模型输出 unified diff 的格式有时也没那么稳,它可能把 hunk 行数写错,或者在 diff 里混几行没前缀的裸文本。

所以我又补了一层格式收口:先从模型输出里提取代码块,再检查 hunk 行前缀,再按实际 body 重新计算 @@ -x,y +u,v @@ 里的行数,尽量减少 git apply --check 因为计数不准直接失败。

这个地方做完以后,Marcodex 才从“会给建议”往前走了一步,变成“能在一个很小的边界里,给我一份可以审、可以落、可以拒绝的修改草稿”。

06

视频这条线,我一开始也没有直接让模型“看整段视频然后理解交互”。

因为在本地机器上,这么干大概率又重又不稳,而且对 UI 录屏来说,真正有价值的往往不是每一帧都看,而是先把关键画面按时间顺序抽出来。

所以 /ask --video 最后做成了“先 ffmpeg/ffprobe 抽帧,再把帧图和时间戳摘要交给模型”。

现在支持三种模式:

  • interval:按固定时间间隔抽帧,最适合 UI 流程先保底不漏。

  • scene:按画面变化程度抽帧,适合抓页面跳转、弹窗出现这种明显变化。

  • iframe:只抽编码 I-frame,作为补充,不作为默认主方案。

我自己现在最常用的还是这个:


marcodex /ask --video ./demo.mp4 \

--frame-mode interval \

--fps 1 \

--max-video-frames 8 \

"按时间顺序说明这段录屏里页面状态怎么变化、问题出在哪"

这个设计背后的判断其实也很简单:能先交给脚本整理的东西,就别直接扔给模型硬猜;模型更适合看整理后的材料,而不是一口吞一整坨原始输入。

07

最后真正落地的时候,我是按四个阶段把 Marcodex 做出来的。

第一阶段,先把全局命令入口和项目状态跑通。

也就是 marcodex/model/status/memory show/add,再加上 ~/.marcodex/global-config.json 和每个项目自己的 .marcodex/

第二阶段,再补 /init/ask

这一步做完以后,它就不再只是“一个 Ollama 启动器”,而是开始能带着项目索引、项目规则和指定文件内容来回答问题。

第三阶段,做 /patch/apply

这一步的重点不是“让模型更自由”,而是把“只改哪些文件、怎么保存 diff、怎么安全应用、怎么留下本轮记录”这件事先工程化。

第四阶段,再补 /ask --videofast-doc

Gemma4 装好之前,fast-doc 先回退到 Gemma3;Gemma4 真下完以后,我直接把这个角色切过去,不用改主程序代码。

现在这套东西已经可以这么用了:


marcodex /init --refresh

marcodex /ask "先结合当前项目索引,帮我说明这个需求大概应该从哪些模块入手"

marcodex /ask --files path/to/A.swift,path/to/B.swift "先解释这几个文件现在的职责和调用关系"

marcodex /patch --files path/to/A.swift "只改这个文件,按最小风险方式修复这个问题"

marcodex /status

marcodex /apply <patch-id>

如果是长文档快速扫读,我就直接用:


marcodex /ask --role fast-doc "先快速总结当前项目文档里和这个需求最相关的约束"

如果这个项目里新形成了一条长期规则,我不会只把它留在聊天窗口里,而是直接写进项目记忆:


marcodex /memory add "已跑通的核心业务流程默认不要大范围重写,优先做低侵入修复"

这样下一轮再问的时候,这条规则会自动进上下文,而不是每次都靠我重新提醒。

08

当然,这套东西现在还不是一个“本地版 Codex 完全替代品”。

我现在对它的定位反而更收敛了:它不是来证明本地小模型已经无所不能的,而是把本地开源模型接进一个更像真实工程工作流的壳里。

所以它现在最适合做的,是这些事:

  • 先读项目结构和规则,再帮我分析一个需求该从哪里下手。

  • 针对我点名的几个文件,解释当前代码在干什么。

  • 在很小的文件范围里,给我一份可以人工 review 的 patch 草稿。

  • 对截图或录屏抽帧做一层辅助分析。

  • 把项目级长期规则和单轮会话记录留在本地。

它不适合做的,我现在也不会硬吹:

  • 让它自己接手一个老项目大模块,然后跨几十个文件稳定重构。

  • 让多个本地大模型并行常驻,像云端 agent 一样自己长时间连续规划。

  • 完全不看 diff、不做人工确认,就让它直接改项目核心流程。

这其实也是这轮做下来,我自己最明确的一个感受。

在本地搭开源大模型,真正有意思的地方,不只是“模型能不能跑起来”,而是你能不能把它放进一个边界足够清楚、上下文足够稳定、出了问题还能收得住的工作流里。

Marcodex 对我来说,就是沿着这个方向先做出来的一个版本。

它不神奇,但它已经开始真的能干活了。

如果后面我继续往下做,下一步大概率会先补三件事:

第一,把 /memory summarize 从“只备份”补成真正的模型压缩;

第二,给 .marcodex/cache/video-frames/ 补一个更明确的缓存清理策略;

第三,给 SafetyGuardPatchManagerContextBuilderModelRouter 这些模块补一组最小测试。

因为工具一旦真的开始被自己天天用,下一步最该补的,往往就不是新功能了,而是那些能让它更稳、更不容易在关键时候添乱的东西。