跳到主要内容

Ally - 我写的桌面 Agent 是怎么跑的

勾玉aniki
博客作者,py&go后端开发,爱好动漫。邮箱tangssst@qq.com

Ally 是一个本地优先的桌面 AI 编程助手,Go + Wails 写的,Windows / macOS / Linux 都有包,代码在 github.com/Bronya0/ally-agent,GPLv3。

下面写它的实现:主循环、工具定义、一批工具的调度、上下文压缩和前缀缓存、边界拦住哪些操作,最后是踩坑记录。

先报个数:212 个 Go 文件、九万三千多行(含 91 个测试文件),前端 142 个文件、四万五千多行,提交 652 次,内置工具 26 个。难看的地方也有,App.vue 单个文件九千四百多行,app.go 三千六百行,现在改前端基本靠搜索。

分层结构​

代码分五层,依赖单向向下,文件前缀就是层级。

主循环​

骨架如下,省掉错误处理和取消:

for step := 0; step < maxAgentSteps; step++ {
drainInjectedMessages() // 运行中用户插话,从队列进历史
if needCompact() { compact() } // 用量越阈值,整段总结
resp := streamModel(messages, tools)
messages = append(messages, assistantOf(resp))

if len(resp.ToolCalls) == 0 {
saveHistory()
return
}
for _, call := range planBatch(resp.ToolCalls) {
messages = append(messages, toolMessageOf(executeTool(call)))
}
}

几个关键处理:

哪些交给模型、哪些写死。 调哪个工具、调几次、什么时候停,这些交给模型判断;阈值、排序、路径校验、编辑契约有唯一答案的,写在代码里。压缩触发条件就试过交给模型判断,结果它对「自己占了多少 token」没概念,长对话里飘得厉害。

状态只有 messages 一个切片。 界面上的东西全部由这条消息流投射,不另外维护一份「当前会话状态」,两份状态对不上时无法判断该信哪个。

步数有硬上限,目前 9999 步,防止模型在一轮里自己绕圈。

退出路径统一走 defer:panic、用户取消、超步数,都走同一套检查点并释放运行标记。

只有工具结果能推进循环。 模型声称改完了不算数,工具真的返回,循环才进下一步。

一个会话同时只跑一个循环。 运行中用户插话进注入队列(上限 32 条),不新开 run,否则两个循环抢同一份历史。

上下文压缩​

每一步开头先算当前用量,和服务商回的实测值对齐,超过上下文窗口的 60%(阈值可配,夹在 20%~95%)就压缩。

压缩只有一条路径:调模型总结整段历史然后替换。以前做过一版「微压缩」,原地改写历史中段,试下来不划算,省下的 token 不够赔前缀缓存。项目笔记里那条写得更短:

[compact-single-path] 2026-09-26 压缩入口(手动/阈值/溢出)只能走同一条路径;历史中段原地改写会作废其后全部缓存。

另一个入口是溢出恢复:服务商明确报上下文超长时,忽略阈值强制压一次再重发。压缩连续失败会熔断一段时间,不然每个 step 都要重付一次长上下文总结。

工具定义​

这块花的时间比系统提示词多。模型每一轮都会重读工具定义,但未必重读那一大段规则,参数怎么写、报错给什么,直接决定它下一步会不会犯错。

读写对账。 read 返回内容时带一个六位 version,edit 必须带回来,对不上返回 E_VERSION_MISMATCH。这是乐观并发,也避免「模型以为的文件」和磁盘上的文件分家。

一处改动只允许一个来源。 要么给 oldText(照抄原文片段),要么给 lineRange(行区间),同时给会被 schema 拒;replaceAll 不许配行区间。oldText 匹配到多处报 E_MULTI_MATCH,不替它猜是不是想改第一处。

错误信息写给模型看。 常用的几个:

E_VERSION_MISMATCH  E_MULTI_MATCH  E_BAD_ARGS  E_TRUNCATED_ARGS
E_WRITE_BATCH_CONFLICT E_ASK_BATCH_CONFLICT E_DUPLICATE_TOOL_CALL

看到 E_MULTI_MATCH 它会多抄两行上下文重试,看到 E_VERSION_MISMATCH 它会先重新读一遍文件。工具错误在界面和模型侧用同一套渲染,两边看到的信息一致。

文件工具不展开 ~。 ~/x 按字面名处理,替模型展开成主目录看着方便,实际会让工具说的位置和真实位置静默错位。

阈值不写进工具描述。 描述里曾经写死过 read 默认读 2000 行,后来实现改了、描述没跟上,模型按错数字判断,静默漏读。

26 个内置工具:

类别工具
文件list_files read edit create delete grep
执行command service wait scheduled_task subagent
交互ask suggest plan skill
网络与展示http_request web_fetch calculate render_html screenshot
远端 SSHssh_cluster remote_read remote_edit remote_create_file remote_delete_path remote_run_command

一批工具一起发过来​

模型一次给七八个调用是常态,按顺序跑会被一个十秒的命令堵住。执行分三段:

  1. 不碰文件的并发跑,并发数 4;
  2. 碰文件的按调用顺序串行,两个 edit 打同一个文件并行跑会互相覆盖;
  3. wait 这种本来就要延后的排到最后。

进执行之前先做冲突判定,命中直接拒:

  • ask / suggest 必须独占整批,要弹给用户,混在批里会变成一边等回答一边改文件;
  • 同一批只允许一次 plan 写入;
  • 同批语义重复的调用去重拒绝;
  • 同一路径同批写两次,只执行最早那次。

结果先给界面、后回填历史。执行完就发事件,卡片立刻出结果,但回填 messages 严格按调用顺序,不然工具结果和调用对不上。

计划工具​

plan 三个动作:read / set / finish,一次调用只允许给一个来源(步骤清单或完成进度),两个一起给当场拒。

状态不由模型写。模型只说哪一步做完了,由位置换算状态:游标之前完成、游标处进行中、之后待办。让模型自己标的时候,长计划里会出现两个「进行中」或者顺序标反,改成位置推导后这类问题没有了。

finish 报最后一步即收尾;报清单上没有的标题会被拒,并把可选标题回显回去,不悄悄新插一行。

计划不进请求前缀,它只是历史里的一次工具调用加一条工具结果。

前缀缓存​

服务商的 prompt cache 是 KV 缓存对最长公共前缀逐字节比对,前缀里任何一处变了,从变化点往后全部作废。对应三条约束:

  1. 工具集在会话首次请求之后冻结,深拷贝一份,防止 MCP 中途启停改了清单;
  2. 系统提示词和工作区地图按会话冻结,运行中改了文件也不重拼;
  3. 消息严格单调追加,不注入临时文本。

请求侧的处理:

  • 缓存键用 ally: 加会话 id 哈希的前 16 字节,只发给声明支持这个字段的端点;
  • Anthropic 侧摆在三个位置:工具尾、system 尾、最新消息尾。thinking 块本身没有 cache_control 字段,打点要往前找;
  • 兼容端点可能只认顶层 cache_control,消息体里的标记会被忽略而且不报错,表现就是命中率一直是 0;
  • 回填 reasoning_content 时两次请求必须字节一致,连 map 序列化的键顺序都要固定。

token 估算:有服务商回的实测用量就用实测,没有就按字符估,ASCII 约 3.2 字符一个 token,中文约 1.4 个,图片固定算 2000。

边界​

  • 受保护路径(.git / .svn / .hg、KB 的 sources/)的判定收在一处,由写、删、命令三条入口共用。路径比较前先归一,Windows 下 Win32 会剥掉每段尾部的点和空格,.git. 就是 .git。这条如果只给 delete 加了保护、漏掉 create 和 edit,往 .git/hooks 写一个文件,下次提交就会执行。
  • 词法围栏还要按 EvalSymlinks 的结果复判,工作区内的软链和 ~ 都能绕。
  • 命令的写根包含临时目录和工具链缓存,不含它们编译跑不起来;文件落盘的写根不能复用这一套,否则工作区里指向临时目录的软链就能写到外面。
  • 远端 SSH 的首次连接、危险命令、覆盖动作都要过审批,主机指纹对不上直接换记录重连并留 .old 备份。
  • 危险命令判定不能按子串,git add . 里含有 dd 加空格,按子串会把日常提交当成磁盘覆写。
  • 插件权限里工作区只能给 none / read,写在校验阶段就拒;但插件页面和主界面同源,真正拦得住的只有 HTTP 主机白名单。

踩坑记录​

项目根目录有个 LESSONS.md,181 行,一行一条,记日期、危害和涉及的代码位置,Ally 每次开工会把最新一段注入系统提示词。挑几条:

  • Git Bash 的 awk/sed 按字节算长度,中文行会虚报,量行宽得用 python 的 len()。
  • 子进程 stderr 必须持续读空,管道满 64KiB 后子进程会阻塞假死。
  • position: fixed 又不写 left/top 的元素按静态位置落位,实测视口高 705、translate y=200 时量出来 top=905,卡片会掉到窗口外面。
  • 读文件先 stat 限长再读,先整读再判断太大,一次普通调用就能把内存打满。

还没弄完的​

  • App.vue 要拆,改动涉及全局回归,得找整块时间。
  • 插件隔离目前只有 HTTP 白名单。
  • macOS 包没签名,装完要手动跑一次脚本清隔离属性。
  • 一个 6px 的运行小圆点改过四次,10-03 一天连改三次(硬闪、慢呼吸、软闪),它在工具卡每拍重绘时由主线程画帧。

Ally 在 github.com/Bronya0/ally-agent,GPLv3,Windows / macOS / Linux 都有包。