
1. 为什么要把 AI 提示流塞进一台路由器第一次跟朋友聊起这个想法的时候对方的表情很微妙——你费这么大劲就为了把 AI 跑在路由器上这个问题我后来被问过不下十次每次我都得从头解释一遍我要的不是把大模型塞进路由器而是把提示词编排这一层逻辑下沉到网络边缘。先说清楚这个项目到底在做什么。核心目标是在华硕路由器搭载 Merlin 固件的型号上运行一个轻量级的 AI 提示流编排器它本身不跑大模型推理而是充当一个边缘网关接收来自局域网内各种设备的请求按照预设的提示流模板进行参数填充、上下文拼接、多轮路由分发再把请求转发给后端的 AI 服务最后把结果整理后返回给调用方。整个编排器用 Go 语言编写编译成 ARM 架构的二进制文件通过 Merlin 插件的机制实现开机自启和 Web 界面管理。为什么偏偏选路由器这个问题值得展开说。我家里和工作室加起来有二十多个联网设备——手机、平板、笔记本、树莓派、智能音箱、各种开发板。如果每个设备都单独去配置 AI 服务的调用逻辑光是 API Key 的管理和提示词版本同步就能把人逼疯。而路由器是所有这些设备流量的必经之路它 7×24 小时在线功耗极低天然就是一个理想的边缘节点。把编排层放在这里所有设备只需要访问一个固定的局域网地址就能拿到统一的 AI 能力不用关心后端用的是哪个服务、提示词是什么版本、上下文怎么管理。另一个驱动力是延迟和隐私的平衡。有些提示流的前置处理——比如敏感词过滤、格式校验、模板变量替换——完全可以在本地完成没必要把原始请求原封不动地发到远端。路由器作为边缘网关可以在请求离开局域网之前就做完这些处理既减少了无效的远端调用也降低了敏感信息泄露的风险。这个项目适合什么人参考如果你手头有一台刷了 Merlin 固件的华硕路由器对 Go 语言有基础了解并且希望给家里的 AI 应用做一个统一的接入层那这篇内容就是写给你的。如果你只是想了解边缘计算和插件开发的思路里面的架构设计和踩坑经验同样有参考价值。整个方案不依赖任何特定的 AI 服务商你可以把它接到自己常用的任何兼容接口上。2. Merlin 插件机制与 Go 交叉编译的配合方式2.1 Merlin 固件的插件加载逻辑华硕 Merlin 固件本质上是华硕官方固件的一个增强分支它保留了官方的核心框架同时开放了一些额外的扩展点。插件的加载机制并不复杂但有几个关键细节如果搞错了插件要么不启动要么启动后找不到依赖。Merlin 的插件通常放在/jffs/分区下这个分区是可读写的重启后数据不会丢失。固件在启动过程中会执行/jffs/scripts/目录下的几个钩子脚本其中services-start是最常用的入口点——它在系统服务启动完成后被调用适合用来拉起我们自己写的常驻进程。另一个重要的钩子是firewall-start它在防火墙规则加载后执行如果你需要开放端口或者做流量重定向这个时机比较合适。我一开始犯的错误是把启动脚本放在了init-start里结果发现网络栈还没完全就绪Go 程序绑定端口时直接报错。后来改到services-start就正常了。这个细节在官方文档里没有明确写是我反复重启路由器试出来的。还有一个容易忽略的点Merlin 的/jffs/分区容量有限不同型号从 32MB 到 128MB 不等。Go 编译出来的二进制文件动辄十几 MB加上配置文件和日志很容易把分区撑满。我的做法是把二进制文件放在/jffs/下但把日志和临时数据通过符号链接指向外接 USB 存储这样既不占用宝贵的 JFFS 空间也方便随时拔下来查看日志。2.2 Go 交叉编译到 ARM 的实操细节路由器用的是 ARM 架构的处理器具体型号不同可能是 ARMv7 也可能是 ARMv8AArch64。你得先确认自己路由器的架构方法是通过 SSH 登录后执行uname -m如果输出是armv7l说明是 32 位 ARM如果是aarch64则是 64 位。这个信息决定了你编译时用的GOARCH和GOARM参数。以 32 位 ARMv7 为例编译命令大致是这样的CGO_ENABLED0 GOOSlinux GOARCHarm GOARM7 go build -ldflags-s -w -o promptflow-armv7 .这里有几个参数值得解释。CGO_ENABLED0是必须的因为路由器上通常没有完整的 C 库和编译工具链禁用 CGO 可以生成纯静态链接的二进制文件避免运行时找不到动态库。-ldflags-s -w用来去掉调试信息和符号表能把二进制体积缩小 20% 到 30%。对于一个十几 MB 的程序来说这个优化在 JFFS 分区紧张的情况下很有意义。编译完成后用scp把二进制文件传到路由器的/jffs/目录下然后chmod x赋予执行权限。第一次运行建议在前台执行方便看日志输出/jffs/promptflow-armv7 --config /jffs/promptflow/config.yaml确认能正常启动、端口能访问之后再把它放到后台运行并配置开机自启。注意Merlin 固件的 BusyBox 环境里没有 systemd不能用 systemctl 管理服务。你需要用start-stop-daemon或者简单的nohup ... 方式来后台运行并在services-start脚本里写好启动逻辑。2.3 插件目录结构与文件权限一个完整的 Merlin 插件目录结构大概长这样/jffs/ ├── scripts/ │ ├── services-start # 开机启动钩子 │ └── services-stop # 关机停止钩子 ├── promptflow/ │ ├── promptflow-armv7 # 编译好的二进制 │ ├── config.yaml # 配置文件 │ ├── templates/ # 提示流模板目录 │ └── logs - /mnt/usb/logs # 日志软链接权限方面/jffs/scripts/下的钩子脚本必须是可执行的chmod x否则固件不会执行它们。另外Merlin 固件在启动时会检查这些脚本的属主建议保持为admin用户不要改成 root否则某些固件版本会拒绝执行。我踩过的一个坑是在 Windows 上编辑脚本后直接传到路由器结果因为换行符是 CRLF 导致脚本执行报错。解决方法是在路由器上用dos2unix转换或者在编辑器里提前设置成 LF 换行。这个坑很隐蔽因为脚本内容看起来完全正常但 shell 就是报not found或者语法错误。3. 提示流编排器的核心架构拆解3.1 请求接入层HTTP 服务与路由设计编排器对外暴露的是一个 HTTP 接口局域网内的设备通过http://路由器IP:端口/v1/flow/{flow_name}这样的路径来调用不同的提示流。选择 HTTP 而不是 gRPC 的原因很简单路由器上的资源有限HTTP 的解析开销更小而且几乎所有编程语言和工具都能方便地发 HTTP 请求接入成本最低。路由设计上我用了 Go 标准库的net/http加上一个轻量级的路由匹配逻辑没有引入 Gin 或 Echo 这类框架。原因有两个一是标准库足够用二是每引入一个第三方依赖交叉编译出来的二进制就会大一圈。在路由器这种存储空间紧张的环境里能省则省。请求进来之后接入层做几件事解析请求体中的 JSON 参数、校验必填字段、根据flow_name找到对应的提示流模板、检查调用频率是否超限。这几步都在本地完成不涉及任何远端调用所以速度很快通常在一两毫秒内就能完成。频率限制这块我用的是令牌桶算法的一个简化实现每个调用方按 IP 或 API Key 区分维护一个计数器超过阈值就返回 429 状态码。这个逻辑写在接入层而不是转发层是为了在请求还没离开路由器之前就挡掉异常流量减少不必要的远端开销。3.2 模板引擎变量替换与上下文拼接提示流的核心是模板。一个模板本质上就是一段带有占位符的文本编排器在运行时把占位符替换成实际的值再发给后端 AI 服务。我用的占位符语法是{{variable_name}}简单直观不容易和正常文本冲突。模板文件放在/jffs/promptflow/templates/目录下每个模板是一个独立的 YAML 文件结构大概是这样name: code_review description: 代码审查提示流 system_prompt: | 你是一位资深代码审查专家请对以下代码进行审查。 重点关注安全性、性能、可读性。 user_prompt: | 请审查以下 {{language}} 代码{{code}}项目背景{{context}} parameters: - name: language required: true - name: code required: true - name: context required: false default: 无额外背景信息模板引擎在加载时会做一次预编译把 YAML 解析成内部结构体这样每次请求进来就不用重新解析文件了。变量替换用的是简单的字符串替换没有用text/template标准库因为标准库的模板语法和我的占位符语法冲突而且功能过剩。上下文拼接是另一个关键点。有些提示流需要多轮对话的上下文编排器需要维护一个会话状态。我的做法是在内存里用一个 LRU 缓存来存储最近若干轮的对话记录每个会话用一个 UUID 标识调用方在请求头里带上这个 UUID 就能续上上下文。缓存大小设成 100 个会话每个会话最多保留 10 轮对话超出就淘汰最久未使用的。这个策略在路由器有限的内存通常 256MB 到 512MB下运行得很稳。3.3 转发层多后端路由与失败重试转发层负责把组装好的请求发给后端的 AI 服务。这里我设计了一个多后端路由机制可以在配置文件里定义多个后端每个后端有不同的权重和优先级。比如你可以配置一个主用的服务和一个备用的服务主用服务连续失败三次就自动切换到备用。backends: - name: primary endpoint: https://api.example.com/v1/chat/completions api_key: sk-xxxx weight: 10 timeout: 30 - name: backup endpoint: https://api.backup.com/v1/chat/completions api_key: sk-yyyy weight: 1 timeout: 60失败重试的逻辑是这样的请求发出后如果超时或者返回 5xx 错误就重试下一个后端如果返回 4xx 错误比如参数错误则不重试直接把错误返回给调用方。重试次数最多两次避免在路由器上堆积太多并发请求。超时设置需要特别注意。路由器的网络环境可能不如服务器稳定超时设太短会导致频繁重试设太长又会占用连接资源。我实测下来30 秒是一个比较平衡的值。如果后端服务响应特别慢可以考虑在编排器里加一个异步模式请求进来后立即返回一个任务 ID调用方轮询查询结果。不过这个模式会增加复杂度我目前还没在路由器上实现。3.4 配置热加载与日志轮转配置文件修改后不希望重启整个服务所以做了一个简单的热加载机制编排器每隔 30 秒检查一次配置文件的修改时间如果发现变化就重新加载。重新加载时用读写锁保护确保正在处理的请求不受影响。日志方面路由器的存储空间有限不能无限写日志。我用的是按大小轮转的策略单个日志文件最大 5MB最多保留 3 个历史文件超出就删除最旧的。日志级别可以在配置文件里调整默认是info排查问题时可以临时改成debug。log: level: info max_size_mb: 5 max_backups: 3 path: /mnt/usb/logs/promptflow.log这里有个细节日志路径最好指向外接 USB 存储而不是 JFFS 分区。因为 JFFS 分区的写入寿命有限频繁写日志会加速磨损。我用的是一个便宜的 U 盘专门用来存日志和临时文件坏了就换不心疼。4. 从零跑通的完整操作链路4.1 路由器端的准备工作在开始部署之前需要确保路由器已经刷了 Merlin 固件并且开启了 SSH 访问和 JFFS 分区。这两项在 Merlin 的 Web 管理界面里都能找到SSH 在系统管理→系统里开启JFFS 在系统管理→系统里有一个启用 JFFS 分区的选项勾选后重启生效。开启 JFFS 后通过 SSH 登录路由器执行df -h确认/jffs分区已经挂载并且有可用空间。如果空间不足 20MB建议先清理一下不必要的文件或者考虑把二进制文件放到外接 USB 存储上。接下来安装一些必要的工具。Merlin 自带的 BusyBox 功能比较精简有些命令可能没有。我通常会装一个entware包管理器它能提供更完整的工具集opkg install curl wget nano htop这些工具不是运行编排器必须的但在调试和排查问题时非常有用。特别是htop能直观地看到编排器进程占用了多少内存和 CPU。4.2 编译与部署的完整命令序列在开发机上我用的是 Linux 环境Windows 下可以用 WSL2先克隆代码仓库然后执行交叉编译git clone https://github.com/yourname/promptflow.git cd promptflow CGO_ENABLED0 GOOSlinux GOARCHarm GOARM7 go build -ldflags-s -w -o promptflow-armv7 .编译完成后检查一下二进制文件的架构是否正确file promptflow-armv7输出应该显示ELF 32-bit LSB executable, ARM, EABI5之类的信息。如果显示的是 x86 架构说明环境变量没设置对。然后通过 scp 传到路由器scp promptflow-armv7 admin192.168.1.1:/jffs/promptflow/传完之后 SSH 登录路由器赋予执行权限并创建配置目录ssh admin192.168.1.1 chmod x /jffs/promptflow/promptflow-armv7 mkdir -p /jffs/promptflow/templates mkdir -p /jffs/promptflow/logs把配置文件写好模板文件也放进去然后前台运行测试/jffs/promptflow/promptflow-armv7 --config /jffs/promptflow/config.yaml如果看到类似listening on :8080的输出说明启动成功了。在局域网内的另一台设备上访问http://192.168.1.1:8080/health如果返回{status:ok}说明服务正常。4.3 开机自启脚本的编写与调试开机自启脚本放在/jffs/scripts/services-start内容大致如下#!/bin/sh if [ -f /jffs/promptflow/promptflow-armv7 ]; then nohup /jffs/promptflow/promptflow-armv7 --config /jffs/promptflow/config.yaml /dev/null 21 logger -t promptflow PromptFlow started with PID $! fi这个脚本要做几件事检查二进制文件是否存在、用nohup后台启动、把 PID 记录到系统日志里方便排查。logger命令会把消息写到系统日志可以通过logread | grep promptflow查看。对应的停止脚本放在/jffs/scripts/services-stop#!/bin/sh PID$(pidof promptflow-armv7) if [ -n $PID ]; then kill $PID logger -t promptflow PromptFlow stopped fi写完脚本后记得chmod x。测试自启是否生效的方法是重启路由器然后 SSH 登录后执行pidof promptflow-armv7如果有输出说明启动成功。我遇到过一个坑脚本里用了nohup但没重定向输出结果程序的标准输出被挂到了一个已经关闭的终端上运行一段时间后进程莫名其妙就死了。后来加上 /dev/null 21就稳定了。这个问题的症状是进程运行几小时后消失日志里没有任何错误信息排查起来很费劲。4.4 验证与压力测试部署完成后建议做一轮简单的压力测试确认在并发请求下不会把路由器搞崩。我用的是abApache Bench或者wrk在局域网内的另一台机器上执行wrk -t2 -c10 -d30s http://192.168.1.1:8080/v1/flow/simple_test这个命令会开 2 个线程、10 个并发连接持续 30 秒。观察路由器的 CPU 和内存占用如果 CPU 持续跑满或者内存不断增长说明有性能问题需要优化。实测下来在 ARMv7 的路由器上编排器本身不含远端 AI 调用能轻松处理每秒几百个请求。瓶颈通常在远端 AI 服务的响应速度上而不是路由器本身。如果发现内存持续增长大概率是会话缓存没有正确淘汰检查一下 LRU 缓存的配置。5. 实际运行中暴露的问题与处理经验5.1 内存泄漏的排查过程项目上线运行了大约一周后我发现路由器的可用内存从最初的 200MB 降到了 80MB 左右。虽然还没到崩溃的程度但这个趋势明显不对。用htop看了一下编排器进程的内存占用从启动时的 15MB 涨到了 60MB。排查内存泄漏的第一步是确认泄漏点。我在代码里加了一个/debug/pprof接口通过 Go 自带的 pprof 工具抓取堆内存快照go tool pprof http://192.168.1.1:8080/debug/pprof/heap对比两个时间点的快照后发现泄漏源是会话缓存。我用的 LRU 缓存实现里淘汰策略只检查了会话数量但没有检查每个会话内部的对话轮数。有些会话积累了上百轮对话每轮对话都占着内存不释放。修复方法是给每个会话加一个最大轮数限制超出就丢弃最早的对话。修复后重新编译部署观察了三天内存稳定在 18MB 左右不再增长。这个问题的教训是在资源受限的环境里任何缓存都必须有明确的上限不能依赖应该不会太多这种假设。5.2 并发请求下的端口耗尽问题另一个比较隐蔽的问题是端口耗尽。编排器作为客户端去请求远端 AI 服务时每次请求都会占用一个本地端口。Linux 默认的端口范围是 32768 到 60999总共约 28000 个端口。在高并发场景下如果请求量很大且连接没有及时释放端口会被耗尽新的请求就会失败报cannot assign requested address错误。解决方法是启用 HTTP 连接复用也就是 Keep-Alive。在 Go 的http.Client里配置Transporttransport : http.Transport{ MaxIdleConns: 100, MaxIdleConnsPerHost: 20, IdleConnTimeout: 90 * time.Second, } client : http.Client{ Transport: transport, Timeout: 30 * time.Second, }这样连接会被复用而不是每次新建端口耗尽的问题就解决了。另外IdleConnTimeout不要设得太短否则连接刚建立就被关闭复用的效果大打折扣。90 秒是一个比较合理的值。5.3 固件升级后插件失效的应对Merlin 固件偶尔会推送更新升级之后/jffs/分区的内容通常会被保留但有时候固件会重置一些系统配置导致插件启动脚本没有被执行。我遇到过两次这种情况表现是路由器重启后编排器没有自动启动。应对方法是在升级固件后手动检查一下/jffs/scripts/下的脚本是否还在、是否有执行权限。如果脚本丢失了从备份恢复即可。我习惯把整个/jffs/promptflow/目录和启动脚本打包备份到本地升级前先备份一次升级后对比一下有没有变化。另外固件升级可能会改变一些系统路径或者 BusyBox 的版本导致之前能用的命令突然不可用。我的建议是在升级后先跑一遍完整的启动流程确认没有问题再恢复正常使用。5.4 日志暴增导致 JFFS 写满的教训有一次我为了排查一个问题把日志级别调成了debug然后忘了改回来。过了两天发现路由器管理界面打不开了SSH 也连不上。重启后勉强登录进去发现/jffs/分区 100% 占满系统无法写入任何文件。原因是 debug 级别的日志量非常大而我的日志轮转配置里max_size_mb设的是 5MB但max_backups设的是 10也就是最多保留 50MB 的日志。JFFS 分区总共才 64MB日志就把空间占满了。修复方法是把日志路径改到外接 USB 存储同时把max_backups降到 3。另外在启动脚本里加了一个检查如果 JFFS 分区使用率超过 90%就自动把日志级别降回info。这个保护机制后来救了我好几次。提示在路由器上跑任何常驻服务都要对存储空间保持警惕。JFFS 分区写满会导致整个系统异常而且恢复起来很麻烦。建议把日志、缓存、临时文件都放到外接存储上JFFS 只放二进制和配置文件。6. 提示流模板的设计心得与扩展思路6.1 模板粒度的取舍设计提示流模板时最容易纠结的是粒度问题模板应该做得多细我的经验是一个模板只做一件事。比如代码审查是一个模板代码翻译是另一个模板代码注释生成又是另一个。不要把多个功能塞进一个模板里用参数来切换那样模板会变得难以维护调用方也容易传错参数。但也不能细到每个变量都单独做一个模板。判断标准是如果两个场景的 system prompt 完全不同那就应该是两个模板如果只是 user prompt 里的变量不同那可以用同一个模板加参数来区分。我目前维护了大约十五个模板覆盖了日常开发中常用的场景代码审查、文档生成、日志分析、配置翻译、正则表达式生成等。每个模板的 YAML 文件都不大通常几十行修改起来很方便。6.2 模板版本管理与回滚模板修改后如果效果变差需要能快速回滚。我的做法是在模板目录下保留最近五个版本的历史文件文件名带上时间戳templates/ ├── code_review.yaml ├── code_review.20250115.yaml ├── code_review.20250110.yaml └── ...每次修改模板前先复制一份当前版本并加上日期后缀。这样如果新版本有问题直接改个文件名就能回滚。这个做法很土但在路由器这种没有版本控制工具的环境里非常实用。另外模板文件修改后不需要重启服务热加载机制会在 30 秒内自动生效。但如果你改的是 YAML 语法加载失败时编排器会保留旧版本继续运行并在日志里记录错误。这个行为是有意设计的避免因为一个语法错误导致整个服务不可用。6.3 把编排器接入更多场景的思路目前这个编排器主要处理文本类的提示流但它的架构其实可以扩展到更多场景。比如接入语音转文字服务路由器接收音频流转发给远端做识别再把识别结果送给 AI 处理最后返回文本。这个流程和现有的文本提示流在架构上是一样的只是多了一个音频格式转换的步骤。另一个扩展方向是和本地设备联动。比如智能音箱可以通过局域网调用编排器把语音指令转成 AI 请求再把结果通过 TTS 返回。路由器作为中间层可以统一管理所有智能设备的 AI 调用不用每个设备都单独配置。还有一个我觉得很有意思的方向是做提示流链式调用一个模板的输出作为另一个模板的输入形成流水线。比如先让 AI 做代码审查再把审查结果送给另一个模板生成修复建议最后把修复建议格式化成可以直接提交的 commit message。这个功能我还在设计中核心难点是链式调用时的错误处理和中间结果缓存。6.4 资源占用的长期观察最后分享一些长期运行的数据。这台路由器是华硕 RT-AX88U四核 ARMv7 处理器512MB 内存。编排器运行了三个月平均 CPU 占用在 2% 到 5% 之间内存稳定在 18MB 到 22MB。每天处理的请求量大约在 200 到 500 次之间峰值出现在晚上偶尔会到 1000 次以上。JFFS 分区占用保持在 15MB 左右二进制 12MB 配置文件 1MB 模板 2MB日志和缓存都在外接 U 盘上。U 盘用的是 16GB 的旧盘三个月写入了大约 2GB 的日志健康度还很好。这个资源占用水平意味着即使路由器上还跑着其他插件比如广告过滤、流量监控编排器也不会成为瓶颈。如果你用的是配置更低的路由器比如 256MB 内存的型号建议把会话缓存调小一些日志级别保持info应该也能稳定运行。我在实际使用中最大的体会是边缘计算的价值不在于算力有多强而在于位置对了。路由器就在那里一直在线所有设备都连着它。把编排层放在这个位置上比放在任何一台需要单独维护的服务器上都更自然。这个项目从想法到跑通大概花了两周时间但后续的调优和踩坑花了将近两个月。如果你也在考虑类似的事情我的建议是先把最小可用的版本跑起来然后在实际使用中慢慢打磨不用一开始就追求完美。