ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从零构建AI编码代理:GUI操控、MCP协议与单文件运行实战

从零构建AI编码代理:GUI操控、MCP协议与单文件运行实战 1. 为什么我要自己造一个AI编码代理市面上AI编码助手已经不少了从IDE插件到云端Agent功能一个比一个花哨。但用了一圈下来我始终有几个需求没被满足第一我希望它能直接操控GUI界面而不只是生成代码片段让我自己复制粘贴第二我希望它能通过MCP协议接入各种外部工具把能力边界打开第三我希望它就是一个单文件下载下来就能跑不需要装一堆依赖、配一堆环境变量。这三个需求叠加在一起找现成的方案基本无解。IDE插件受限于宿主环境云端Agent又要求你把代码传到别人服务器上而支持MCP的工具往往依赖复杂的运行时。于是我花了大概两周的业余时间做了一个自己的AI编码代理核心目标就三个操控GUI、支持MCP、单文件运行。这篇文章不是产品发布稿而是把我从架构设计到踩坑调试的完整过程拆开来讲。如果你也在做类似的事情或者想了解AI编码代理到底是怎么运作的这篇内容应该能帮你省下不少试错时间。我会讲清楚每个关键决策背后的逻辑给出可以直接参考的实现思路以及那些文档里不会写的坑。2. 整体架构设计与核心思路拆解2.1 三个核心约束如何倒逼架构选型做任何工具先定约束再谈方案。我的三个硬性约束直接决定了技术选型的方向。单文件运行意味着不能依赖pip install一堆包不能要求用户预装Node.js或Python环境。最稳妥的做法是用编译型语言写一个静态链接的二进制文件或者用脚本语言但把所有依赖打包进去。我最终选了Go原因后面细说。操控GUI意味着代理需要有能力模拟鼠标点击、键盘输入、截屏识别界面元素。这在不同操作系统上的实现差异很大Windows有Win32 APImacOS有Accessibility APILinux有X11协议。要跨平台就得抽象一层。支持MCP意味着代理需要实现MCP客户端协议能够连接MCP服务器并调用其提供的工具。MCP基于JSON-RPC 2.0通信方式支持stdio和SSE两种。这部分需要设计一个灵活的插件式架构让代理能动态发现和调用外部工具。把这三个约束放在一起架构轮廓就出来了一个静态编译的二进制文件内置GUI操控模块和MCP客户端模块通过一个统一的工具调用层把两者暴露给LLM。LLM可以是本地的也可以是远程的代理本身只负责编排和调度。2.2 为什么选Go而不是Python或RustPython是AI领域的主流语言生态最丰富但打包成单文件是个噩梦。PyInstaller打出来的包动辄上百MB启动慢还经常在目标机器上缺库。Rust性能好、单文件友好但开发效率偏低尤其是涉及GUI操控这种需要大量系统调用的场景写起来很啰嗦。Go在这两者之间找到了平衡点。标准库自带跨平台系统调用封装编译出来就是一个静态二进制交叉编译也方便。GUI操控方面Windows可以用syscall直接调user32.dllmacOS可以用cgo调CoreGraphicsLinux可以用X11的Go绑定。MCP客户端用Go写JSON-RPC也很自然。最终编译出来的二进制大概15MB启动时间在50ms以内完全满足单文件运行的要求。提示如果你也想做类似工具语言选择上优先考虑编译型语言。解释型语言在单文件分发这个场景下会带来很多额外的工程负担。2.3 代理的核心循环感知、决策、执行AI编码代理的本质是一个循环感知当前状态让LLM做决策执行决策对应的动作然后回到感知。这个循环听起来简单但每个环节都有讲究。感知环节代理需要收集的信息包括当前屏幕截图、活动窗口标题、剪贴板内容、文件系统状态、MCP工具列表。这些信息不能一股脑全塞给LLM需要做筛选和压缩。我的做法是给每类信息设一个token预算超出部分做摘要。决策环节LLM需要输出结构化的动作指令。我用的是类似ReAct的格式让LLM先输出思考过程再输出具体的工具调用。工具调用格式定义了一套DSL包括gui.click、gui.type、gui.screenshot、mcp.call、file.read、file.write等。执行环节代理解析LLM的输出调用对应的工具函数把结果返回给下一轮循环。这里的关键是错误处理——GUI操控经常失败比如点击位置不对、窗口没找到、输入法状态异常。代理需要能识别这些失败并让LLM重新决策而不是直接崩溃。3. GUI操控模块的实现细节3.1 跨平台GUI操控的抽象层设计GUI操控的跨平台抽象是这块最麻烦的地方。Windows、macOS、Linux三套系统API完全不同但代理需要暴露统一的接口给LLM。我的做法是定义一个GUIController接口包含以下方法type GUIController interface { Screenshot() ([]byte, error) Click(x, y int) error DoubleClick(x, y int) error RightClick(x, y int) error Type(text string) error KeyPress(key string, modifiers ...string) error MoveMouse(x, y int) error Scroll(x, y int, delta int) error GetActiveWindow() (WindowInfo, error) ListWindows() ([]WindowInfo, error) FindElement(selector string) (Rect, error) }每个平台实现这个接口。Windows用user32.dll和gdi32.dll的syscallmacOS用CoreGraphics和Accessibility的cgo绑定Linux用X11的Go绑定。编译时通过build tag选择对应实现最终每个平台的二进制只包含自己需要的代码。FindElement是最复杂的方法。理想情况下代理应该能像Selenium那样通过选择器找到界面元素但原生GUI没有DOM。我的方案是结合截屏OCR和系统无障碍API在Windows上用UI Automation在macOS上用Accessibility API在Linux上用AT-SPI。如果无障碍API不可用就退化为OCR加模板匹配。3.2 截屏与视觉理解的实际处理流程截屏本身不难难的是让LLM理解截屏内容。直接把PNG丢给多模态LLM是可以的但token消耗大而且LLM对界面元素的空间定位经常不准。我的处理流程是这样的先截全屏然后用无障碍API获取所有可交互元素的边界框和文本标签把这些信息格式化成结构化文本连同压缩后的截图一起发给LLM。这样LLM既能看到视觉布局又能拿到精确的元素坐标。type UIElement struct { Role string // button, textfield, menu, etc. Label string Rect Rect Enabled bool Focused bool Children []UIElement }发给LLM的格式大概是这样[Screen 1920x1080] [Window Visual Studio Code focused] [Button Run at (1200, 45) size 60x30] [Textfield Search at (400, 45) size 300x30 focused] [Menu File at (10, 10) size 40x20] ... [Screenshot: base64 encoded, downscaled to 960x540]这样LLM做决策时既可以用坐标点击也可以用元素标签定位。实测下来带结构化元素信息的准确率比纯截图高很多尤其是在密集UI上。3.3 输入模拟的坑输入法、焦点与权限输入模拟看起来简单实际上坑很多。第一个坑是输入法状态。如果系统当前是中文输入法你模拟键盘输入英文字符可能会触发候选框导致输入内容错乱。我的做法是在输入前先检测输入法状态如果是非英文输入法先切换到英文。第二个坑是焦点问题。模拟点击后目标窗口不一定立即获得焦点尤其是跨进程操作时。需要在点击后加一个短暂的等待然后验证焦点窗口是否正确。如果不正确可能需要先调用SetForegroundWindow。第三个坑是权限。macOS上模拟输入需要辅助功能权限Windows上如果目标程序以管理员权限运行普通权限的代理无法操控它。这些都需要在文档里明确说明并在程序启动时做检测和提示。注意在macOS上首次运行代理时需要引导用户到系统设置里授予辅助功能权限。这个权限请求不能自动弹出必须用户手动操作。建议在程序启动时检测权限状态如果未授予就打印详细的引导信息。4. MCP协议接入的完整实现4.1 MCP协议核心概念快速梳理MCP全称Model Context Protocol是一个让LLM应用与外部工具和数据源通信的开放协议。核心概念有三个MCP服务器提供工具、资源和提示模板MCP客户端连接服务器并调用这些能力传输层负责消息传递支持stdio和SSE两种方式。MCP服务器暴露的能力分三类Tools是可调用的函数Resources是可读取的数据Prompts是预定义的提示模板。对编码代理来说最常用的是Tools。每个Tool有名字、描述和输入schema代理把这些信息转成LLM能理解的格式让LLM决定调用哪个Tool、传什么参数。协议基于JSON-RPC 2.0消息格式很标准。初始化时客户端发送initialize请求服务器返回支持的能力列表。之后客户端可以发送tools/list获取工具列表发送tools/call调用具体工具。4.2 在单文件代理中嵌入MCP客户端在Go里实现MCP客户端核心是处理JSON-RPC的消息编解码和stdio通信。stdio模式下MCP服务器作为子进程启动客户端通过stdin写请求、从stdout读响应。这里有个细节MCP协议要求每条消息是单行JSON以换行符分隔。所以读写时要用bufio.Scanner按行处理不能用普通的json.Decoder。type MCPClient struct { cmd *exec.Cmd stdin io.WriteCloser stdout *bufio.Scanner nextID int pending map[int]chan Response } func (c *MCPClient) Call(method string, params interface{}) (json.RawMessage, error) { id : c.nextID c.nextID req : Request{ JSONRPC: 2.0, ID: id, Method: method, Params: params, } data, _ : json.Marshal(req) c.stdin.Write(append(data, \n)) ch : make(chan Response, 1) c.pending[id] ch resp : -ch return resp.Result, resp.Error }启动MCP服务器时需要把服务器配置传给代理。配置格式我参考了主流MCP客户端的做法用一个JSON文件描述每个服务器的启动命令和参数{ mcpServers: { filesystem: { command: npx, args: [-y, modelcontextprotocol/server-filesystem, /path/to/dir] }, github: { command: npx, args: [-y, modelcontextprotocol/server-github], env: {GITHUB_TOKEN: xxx} } } }代理启动时读取这个配置为每个服务器启动一个子进程建立MCP连接拉取工具列表。所有服务器的工具汇总后统一暴露给LLM。4.3 工具发现、调用与结果回传的完整链路工具发现的流程是代理启动时并发连接所有配置的MCP服务器对每个服务器调用initialize完成握手然后调用tools/list获取工具列表。每个工具的信息包括名称、描述、输入schema。代理把这些信息格式化成LLM能理解的工具定义。工具调用时LLM输出一个工具调用请求代理根据工具名称路由到对应的MCP服务器构造tools/call请求等待响应然后把结果返回给LLM。这里有个关键设计工具名称需要加服务器前缀避免冲突比如filesystem.read_file、github.create_issue。结果回传时要注意MCP的响应格式。tools/call的返回是一个内容数组每项有类型和值。类型可能是text、image、resource等。代理需要把这些内容转成LLM能理解的格式文本直接拼接图片转成base64资源引用转成描述。提示MCP服务器的启动可能比较慢尤其是基于npx的服务器首次启动需要下载包。建议代理启动时异步初始化MCP连接不要阻塞主流程。同时要设置合理的超时避免某个服务器卡死导致整个代理不可用。5. 单文件运行与LLM集成的工程实践5.1 静态编译与资源嵌入的具体操作Go的静态编译很简单设置CGO_ENABLED0就能编译出不依赖系统库的二进制。但GUI操控模块需要cgo调用系统API这就矛盾了。我的解决方案是Windows和Linux上用纯syscall实现不需要cgomacOS上必须用cgo但macOS的二进制本来就不要求完全静态动态链接系统框架是正常的。资源嵌入用Go 1.16引入的embed包。把默认配置、提示模板、图标等资源嵌入二进制//go:embed assets/* var assetsFS embed.FS func loadPrompt(name string) string { data, _ : assetsFS.ReadFile(assets/prompts/ name) return string(data) }编译命令# Windows GOOSwindows GOARCHamd64 CGO_ENABLED0 go build -ldflags-s -w -o agent.exe # Linux GOOSlinux GOARCHamd64 CGO_ENABLED0 go build -ldflags-s -w -o agent # macOS (需要cgo) GOOSdarwin GOARCHarm64 CGO_ENABLED1 go build -ldflags-s -w -o agent-ldflags-s -w去掉符号表和调试信息能把二进制体积减小30%左右。最终Windows版本约12MBLinux版本约14MBmacOS版本约18MB。5.2 LLM接口的抽象与多模型适配代理本身不绑定特定LLM而是定义一个LLMProvider接口支持OpenAI兼容的API、Anthropic API、以及本地模型通过Ollama等。接口设计如下type LLMProvider interface { Chat(ctx context.Context, messages []Message, tools []ToolDef) (Response, error) Name() string } type Message struct { Role string Content string ToolCalls []ToolCall ToolCallID string } type ToolDef struct { Name string Description string Parameters json.RawMessage }不同提供商的差异主要在消息格式和工具调用格式上。OpenAI用tool_calls字段Anthropic用content数组里的tool_use块。适配层负责转换。这样切换模型只需要改配置不用改代码。配置里指定提供商和模型{ llm: { provider: openai, model: gpt-4o, apiKey: sk-xxx, baseURL: https://api.openai.com/v1 } }5.3 上下文管理与token预算控制代理运行过程中上下文会不断增长每轮循环都追加截屏信息、工具调用结果、LLM响应。如果不加控制很快就会超出模型的上下文窗口。我的策略是分层管理。系统提示和工具定义是固定的占用固定预算。对话历史保留最近N轮完整内容更早的做摘要压缩。截屏信息只保留最近3轮更早的丢弃。工具调用结果如果很长只保留摘要和关键字段。具体实现上我维护一个ContextManager每轮循环结束后调用Compact()方法。Compact的逻辑是计算当前token数如果超过阈值比如模型窗口的70%就从最老的消息开始压缩。压缩方式有两种对于工具调用结果用LLM生成摘要对于对话消息合并相邻的同类消息。func (cm *ContextManager) Compact() { for cm.TokenCount() cm.Budget { oldest : cm.Messages[0] if oldest.Role tool { summary : cm.summarize(oldest.Content) cm.Messages[0] Message{Role: tool, Content: summary} } else { cm.Messages cm.Messages[1:] } } }注意压缩上下文时一定要保留系统提示和工具定义这两部分丢了代理就废了。另外压缩后的摘要要标注清楚是摘要避免LLM把摘要当成原始信息。6. 实操中踩过的坑与排查技巧6.1 GUI操控失败的典型场景与修复GUI操控失败是最常见的问题我整理了几种典型场景和对应的排查思路。点击位置偏移在高DPI屏幕上截屏的像素坐标和实际屏幕坐标不一致。Windows上需要调用SetProcessDpiAwareness声明DPI感知macOS上需要处理Retina缩放。我的做法是统一用逻辑坐标在截屏时记录缩放比例点击时做转换。元素找不到无障碍API返回的元素树可能不完整尤其是自绘UI比如游戏、Electron应用的部分区域。这时候需要退化为OCR加模板匹配。我用的是Tesseract做OCROpenCV做模板匹配虽然慢一点但覆盖率高。输入乱码前面提到的输入法问题。除了切换输入法还有一个方案是用剪贴板粘贴代替键盘输入。先把文本写入剪贴板然后模拟CtrlV。这个方案对中文、特殊字符都友好缺点是需要处理剪贴板的保存和恢复。窗口焦点丢失模拟操作时如果用户手动切换了窗口操作会作用到错误的窗口上。我的做法是在每次操作前验证活动窗口如果不匹配就重新激活目标窗口。同时提供一个独占模式在代理运行时锁定用户输入。6.2 MCP连接异常排查速查表MCP相关的异常也不少我整理了一个速查表现象可能原因排查方法解决方案服务器启动失败命令不存在或参数错误手动执行配置里的命令检查command和args确认依赖已安装初始化超时服务器启动慢或卡死查看服务器stderr输出增加超时时间检查服务器日志工具列表为空服务器未实现tools能力调用tools/list看返回确认服务器版本检查能力声明工具调用报错参数schema不匹配对比输入schema和实际参数修正参数格式检查必填字段响应解析失败消息格式不对打印原始响应确认是单行JSON检查编码连接断开服务器进程退出检查进程状态实现自动重连记录退出原因排查MCP问题时最关键的是能看到原始消息。我在代理里加了一个debug模式开启后把所有JSON-RPC消息打印到日志文件。这样出问题时能快速定位是客户端问题还是服务器问题。6.3 单文件分发的兼容性处理经验单文件分发听起来简单实际上面临不少兼容性问题。glibc版本Linux上如果目标机器glibc版本低于编译机器二进制会跑不起来。解决方案是用musl静态链接或者用较老的glibc版本编译。我用的是Alpine容器加musl编译出来的二进制兼容性最好。macOS签名macOS上未签名的二进制会被Gatekeeper拦截。解决方案是自签名或者引导用户手动允许。自签名需要Apple开发者账号个人项目可以用ad-hoc签名但用户首次运行仍需手动允许。Windows杀毒误报Go编译的二进制有时会被杀毒软件误报。解决方案是提交到微软的误报申诉或者用代码签名证书签名。个人项目的话在文档里说明情况引导用户添加信任。配置文件路径不同系统的配置目录约定不同。Windows用%APPDATA%macOS用~/Library/Application SupportLinux用~/.config。代理需要自动检测系统并选择正确的路径同时支持通过环境变量覆盖。7. 代理的实际使用场景与效果7.1 自动化编码工作流的真实案例我日常用得最多的场景是自动化处理重复性编码任务。比如批量重命名变量、批量修改配置文件、批量生成测试用例。这些任务本身不复杂但手动做很费时间。举个例子我有个项目需要把所有的log.Printf替换成结构化日志。手动改的话要打开几十个文件逐个替换。用代理的话我只需要告诉它把项目中所有log.Printf调用替换成logger.Info参数保持原样。代理会自动扫描文件、识别调用、生成替换后的代码、运行测试验证。整个过程代理会调用MCP的filesystem工具读写文件调用GUI工具在IDE里执行格式化调用终端工具运行测试。如果测试失败它会分析错误并重新修改。这个循环跑下来原本需要半小时的工作压缩到几分钟。7.2 GUI操控在真实软件中的表现GUI操控模块在标准控件上表现很好比如按钮、输入框、菜单。但在自绘UI上就差一些。我测试过几个常见软件软件控件类型操控成功率备注VS CodeElectron自绘85%需要OCR辅助Chrome混合90%无障碍API覆盖好Photoshop自绘60%大量依赖图像识别记事本原生99%标准Win32控件终端原生95%文本区域识别准确成功率不高的场景代理会多次尝试并让LLM调整策略。比如Photoshop里点击工具图标第一次可能点偏代理会截屏确认发现没选中就重新计算坐标再点。7.3 性能与资源占用的实测数据代理本身的资源占用很低。空闲时内存约30MBCPU接近0。执行任务时内存峰值约150MB主要是截屏和图像处理CPU峰值约20%单核。延迟方面一轮完整的感知-决策-执行循环在GPT-4o上大约3-5秒其中LLM调用占大头2-4秒本地处理截屏、元素提取、工具执行占1秒左右。如果用本地模型比如通过Ollama跑的Llama 3延迟会增加到8-15秒但隐私性更好。MCP工具调用的额外延迟取决于服务器实现。filesystem这类本地服务器延迟在10ms以内github这类远程API服务器延迟在200-500ms。代理会并发调用多个工具所以总延迟不会线性叠加。8. 后续可以继续扩展的方向这个代理目前还是个原型但已经能覆盖我大部分日常需求。后续我打算在几个方向上继续打磨。多模态增强目前截屏理解主要靠无障碍API加OCR未来可以接入更强的视觉模型直接理解界面截图减少对系统API的依赖。这样在自绘UI上的表现会好很多。任务规划能力现在的代理是单步决策每轮只做一个动作。对于复杂任务可以引入任务分解和规划让代理先制定计划再执行。这样能处理更长的任务链减少中间步骤的来回。协作模式目前是单代理工作未来可以支持多代理协作比如一个代理负责编码一个代理负责测试一个代理负责代码审查。通过MCP协议互相通信形成一个小型开发团队。本地模型优化现在本地模型的效果和速度都还不够理想。随着端侧模型能力提升未来可以做到完全离线运行隐私性和响应速度都会更好。我在实际使用中最大的体会是AI编码代理的价值不在于替代程序员而在于把那些重复、琐碎、需要来回切换上下文的操作自动化掉。它更像是一个不知疲倦的助手你告诉它做什么它就去执行遇到问题会反馈但最终的判断和决策还是在你手里。这个定位想清楚了工具的设计方向也就清晰了。
返回列表