1. 为什么要在路由器上跑 AI 提示流
把 AI 引擎塞进一台华硕路由器,听起来像是极客的恶趣味,但真做过一轮之后你会发现,这个方向解决的是一个非常具体的痛点:家庭和小型办公网络里,越来越多的智能请求需要就近处理,而把所有数据都往云端丢既不经济也不够快。我最初动这个念头,是因为家里那台常年开着的华硕路由器(刷了 Merlin 固件)算力其实一直闲着,而我又不想为了跑几个提示词编排任务专门再开一台小主机或者租一台云服务器。
所谓AI 提示流编排器,说白了就是把多个 AI 调用步骤串成一条流水线:先做意图识别,再决定走哪个模型,接着做参数填充、结果校验、格式转换,最后把结果吐给下游。这套东西如果放在云端,每次都要走一遍网络往返,延迟叠加起来很可观;而放在路由器这种边缘网关上,局域网内的设备请求可以在一跳之内完成编排,响应快、隐私好、还省了云端的调用成本。
关键词里提到的Merlin、边缘网关、Go三个词,基本勾勒出了整个方案的技术骨架。Merlin 是华硕路由器的第三方固件生态,它提供了比原厂固件更开放的软件包管理能力和可执行环境;边缘网关指的是路由器在这个架构里扮演的角色——它不再只是转发数据包,而是承担一部分计算和决策;Go 语言则是实现这个编排器的首选,因为它的交叉编译极其方便、运行时依赖少、内存占用可控,非常适合在路由器这种资源受限的设备上跑。
这篇文章适合三类人看:一是手里有华硕路由器、刷了 Merlin 固件、想折腾点新玩法的玩家;二是做 IoT 或边缘计算、需要理解轻量网关如何承载 AI 编排的开发者;三是想学 Go 语言在嵌入式/边缘场景落地实践的工程师。我会从硬件与固件的可行性判断讲起,一路讲到编排器的核心设计、部署细节和实测踩坑,尽量把每一步的“为什么”都说清楚。
需要先说明一点:路由器上的资源是有限的,别指望它能跑动大模型推理。我们做的是编排,也就是调度和串联,真正的模型推理还是交给局域网内的其他算力节点或者云端 API。路由器在这里的价值是“就近调度 + 协议转换 + 缓存加速”,把这个定位想清楚,后面的设计才不会跑偏。
2. 华硕路由器 + Merlin 固件的可行性边界
2.1 先搞清楚你手里这台机器能不能干活
不是所有华硕路由器都适合干这件事。核心看三个指标:CPU 架构、可用内存、以及 JFFS 分区大小。Merlin 固件支持的大部分中高端型号(比如 RT-AX88U、RT-AC86U 这类)用的是 ARM 架构,Go 交叉编译到 ARM 非常成熟,这是前提。内存方面,我建议至少 512MB,因为 Go 运行时本身加上编排逻辑,再留出缓存空间,256MB 会非常紧张。
你可以通过 SSH 登录路由器后跑几条命令快速摸底:
# 查看 CPU 架构 uname -m # 查看内存总量和可用量 free -m # 查看 JFFS 可用空间 df -h | grep jffs我实测下来,RT-AX88U 这类机器uname -m返回aarch64,内存 1GB,JFFS 有几十 MB 可用,跑一个轻量 Go 服务绰绰有余。但如果你的是入门型号,内存只有 256MB,那就要慎重了,可能跑起来之后路由器本身的转发性能都会受影响。
提示:JFFS 分区是用来持久化存储的,重启不丢。但它的读写寿命有限,不要把高频写入的日志或缓存放在这里,建议把运行时数据放到
/tmp或者挂载一个 U 盘。
2.2 Merlin 固件给了你哪些原厂没有的能力
原厂固件最大的问题是封闭,你没法方便地安装自定义软件包。Merlin 固件在这方面开放了很多,它内置了Entware的支持,Entware 是一个面向嵌入式设备的软件包管理器,你可以用它安装 Python、curl、jq 这类工具。但要注意,Entware 里的 Go 工具链往往版本较老,我建议不要在路由器上编译 Go 程序,而是在开发机上交叉编译好,再把二进制文件传上去。
Merlin 还提供了JFFS 脚本钩子,比如services-start、firewall-start这些,可以让你在路由器启动时自动拉起自己的服务。这是实现“开机自启”的关键,后面部署章节会详细讲。
另外一个容易被忽略的点是Merlin 的防火墙和端口管理。默认情况下,路由器对外的端口是关闭的,你要跑一个 HTTP 服务给局域网设备调用,需要在防火墙规则里放行对应端口,或者干脆只监听内网网段。我一般选择后者,安全省事。
2.3 资源受限下的设计取舍
在路由器上做开发,最重要的一条原则是能省则省。我总结了几个具体的取舍:
| 取舍点 | 云端做法 | 路由器上的做法 | 原因 |
|---|---|---|---|
| 模型推理 | 本地加载模型 | 只做编排,推理外调 | 算力不够 |
| 数据存储 | 数据库 | 内存缓存 + 文件 | 减少 IO 和依赖 |
| 并发模型 | 大规模线程池 | 有限 goroutine + 队列 | 内存和 CPU 有限 |
| 日志 | 全量落盘 | 环形缓冲 + 按需输出 | 保护 JFFS 寿命 |
| 依赖管理 | 随便引库 | 尽量用标准库 | 减小二进制体积 |
这张表是我踩了不少坑之后总结出来的。比如一开始我想在路由器上直接存调用历史到 SQLite,结果发现 SQLite 的写入会频繁触发 JFFS 擦写,而且 CGO 编译出来的二进制体积大了一倍。后来改成内存里维护一个固定长度的环形缓冲,需要持久化的时候再异步写文件,问题就解决了。
3. 提示流编排器的核心架构设计
3.1 编排器到底在编排什么
很多人对“提示流编排”这个概念比较模糊,我用一个具体场景来解释。假设你在家里搭了一个语音助手,用户说“帮我把客厅灯调暗一点,然后放点轻音乐”。这句话进来之后,需要经过这么几步:
- 意图识别:判断这是两个意图——调灯和放音乐。
- 参数抽取:从“调暗一点”里抽出设备是客厅灯、动作是调暗;从“轻音乐”里抽出音乐类型。
- 路由决策:调灯走本地设备控制接口,放音乐走某个音乐服务。
- 执行与校验:分别调用,检查是否成功。
- 结果聚合:把两个结果合并成一句自然的回复。
这一整条链路就是一条提示流。编排器的职责,就是定义这条流、按顺序执行、处理中间的错误和分支。在路由器上,我们不可能用太重的工作流引擎,所以核心设计要足够轻。
我的做法是用Go 的 goroutine + channel来实现一个极简的 DAG(有向无环图)执行器。每个节点是一个处理函数,节点之间通过 channel 传递数据。这样既利用了 Go 的并发优势,又不需要引入任何外部工作流库。
3.2 用 Go 实现一个极简 DAG 执行器
先看核心数据结构。一个节点包含它的名字、处理函数、以及下游节点的列表:
type Node struct { Name string Handler func(ctx context.Context, input []byte) ([]byte, error) Next []string } type Pipeline struct { nodes map[string]*Node entry string }执行的时候,从入口节点开始,每个节点处理完把结果发给所有下游。这里有个细节:如果下游有多个节点,是并行执行还是串行?我的选择是并行,因为路由器上大部分编排步骤是 IO 密集型的(等 API 返回),并行能显著降低总延迟。但并行也带来了结果聚合的问题,所以我用一个sync.WaitGroup加一个结果收集 channel 来处理。
func (p *Pipeline) Run(ctx context.Context, input []byte) ([]byte, error) { results := make(chan []byte, len(p.nodes)) var wg sync.WaitGroup wg.Add(1) go p.execNode(ctx, p.entry, input, results, &wg) go func() { wg.Wait() close(results) }() var final []byte for r := range results { final = r // 简化处理,实际需要按节点聚合 } return final, nil }这段代码是简化版,实际实现里我加了节点级别的超时控制、错误传播和结果合并策略。但核心思路就是这样:用最少的抽象,把编排逻辑跑起来。
3.3 为什么不用现成的工作流引擎
你可能会问,为什么不用 Temporal、Argo 这类成熟的工作流引擎?答案很简单:它们在路由器上跑不起来。这些引擎要么依赖数据库,要么依赖消息队列,要么二进制体积几十 MB 起步。路由器那点资源,光是启动它们就撑不住。
我试过把某个轻量工作流库交叉编译到 ARM,结果二进制 40 多 MB,JFFS 直接放不下。后来自己写的这个 DAG 执行器,编译出来不到 8MB,内存占用稳定在 20MB 以内,这才是路由器能接受的量级。
注意:自己写执行器意味着你要自己处理超时、重试、错误传播这些细节。别小看这些,我第一版就是因为没做超时控制,某个下游 API 卡住之后整个编排器都挂死了。
4. 把 Go 二进制塞进路由器的完整流程
4.1 交叉编译:在开发机上生成 ARM 可执行文件
Go 的交叉编译是它最大的优势之一。假设你的路由器是 ARM64 架构(aarch64),在开发机上只需要设置两个环境变量:
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="-s -w" -o orchestrator这里有几个关键点要解释。CGO_ENABLED=0是必须的,因为路由器上没有完整的 C 库,开启 CGO 会导致二进制依赖动态链接库,传上去跑不起来。-ldflags="-s -w"是去掉调试信息和符号表,能把二进制体积缩小 30% 左右。我实测一个中等复杂度的编排器,不加这个参数是 12MB,加了之后 8MB 出头。
编译完之后,用file命令确认一下架构对不对:
file orchestrator # 应该输出类似:ELF 64-bit LSB executable, ARM aarch64如果输出的是 x86-64,说明环境变量没生效,检查一下是不是在命令前正确设置了。
4.2 传输与权限设置
把二进制传到路由器上,我习惯用scp:
scp orchestrator admin@192.168.50.1:/jffs/orchestrator/传上去之后,SSH 登录路由器,给它加上可执行权限:
chmod +x /jffs/orchestrator/orchestrator这里有个坑:Merlin 固件的 JFFS 分区默认可能没有挂载可执行权限。如果你执行的时候报Permission denied,检查一下挂载参数:
mount | grep jffs如果看到noexec,那就需要重新挂载或者把二进制放到/tmp下执行。我一般选择后者,因为/tmp是内存文件系统,执行权限没问题,而且读写速度快。但缺点是重启会丢,所以启动脚本里要包含“从 JFFS 拷贝到 /tmp 再执行”的逻辑。
4.3 开机自启:用 Merlin 的 services-start 钩子
Merlin 固件会在启动时执行/jffs/scripts/目录下的几个钩子脚本,其中services-start是最适合拉起自定义服务的。我的做法是创建一个启动脚本:
#!/bin/sh # /jffs/scripts/services-start # 等待网络就绪 sleep 30 # 拷贝二进制到内存文件系统 cp /jffs/orchestrator/orchestrator /tmp/orchestrator chmod +x /tmp/orchestrator # 启动服务,日志重定向到文件 /tmp/orchestrator -config /jffs/orchestrator/config.json >> /tmp/orchestrator.log 2>&1 &别忘了给脚本加执行权限:
chmod +x /jffs/scripts/services-start那个sleep 30是我踩坑之后加的。一开始没加,结果服务启动的时候网络还没完全就绪,编排器尝试连接下游 API 全部失败。后来加了等待时间,问题解决。具体等多久取决于你的路由器启动速度,可以先用sleep 60保守一点,稳定之后再往下调。
5. 轻量边缘网关的通信与协议转换
5.1 为什么路由器适合做协议转换层
家庭网络里的设备协议五花八门:有的用 HTTP,有的用 MQTT,有的用私有 TCP 协议,还有的只支持串口。如果每个上层应用都要自己去适配这些协议,开发成本极高。而路由器作为网络的中心节点,天然适合做协议转换层——把各种异构协议统一成一种内部格式,上层应用只需要跟路由器打交道。
我在编排器里设计了一个适配器层,每个适配器负责一种协议。比如 MQTT 适配器订阅某个主题,收到消息后转成内部 JSON 格式,再交给编排器处理。这样上层逻辑完全不用关心底层是什么协议。
type Adapter interface { Name() string Start(ctx context.Context, out chan<- Message) error Send(ctx context.Context, msg Message) error } type Message struct { Source string Payload []byte Meta map[string]string }这个接口设计的关键是out chan<- Message,适配器把收到的消息往 channel 里丢,编排器从 channel 里读。这样适配器和编排逻辑完全解耦,加一个新协议只需要实现这个接口。
5.2 局域网内的服务发现与调用
编排器需要调用局域网内的其他服务,比如模型推理节点、设备控制接口。这些服务的地址如果写死在配置里,一旦 IP 变了就要改配置。我的做法是用mDNS做服务发现,让各个服务自己广播自己的存在。
Go 里有现成的 mDNS 库,但考虑到路由器资源有限,我简化了一下,用一个静态配置 + 健康检查的方案:配置文件里列出所有下游服务的地址,编排器定期 ping 它们的健康检查接口,把可用的服务维护在一个列表里。调用的时候从可用列表里选。
{ "services": [ {"name": "llm-node", "url": "http://192.168.50.10:8080/health", "weight": 1}, {"name": "device-ctrl", "url": "http://192.168.50.20:9090/health", "weight": 1} ], "health_check_interval": 30 }这个方案比 mDNS 简单,但足够用。weight字段是为了后面做负载均衡预留的,如果同一个服务有多个实例,可以按权重分配请求。
5.3 请求缓存与去重
边缘网关有一个天然优势:它能看到所有经过它的请求。这意味着它可以在本地做缓存和去重。比如同一个提示词在短时间内被多个设备请求,编排器可以只调用一次下游,然后把结果分发给所有请求方。
我用一个带过期时间的 map 来实现这个缓存:
type Cache struct { mu sync.RWMutex items map[string]cacheItem } type cacheItem struct { value []byte expires time.Time }缓存 key 是提示词内容的哈希,value 是下游返回的结果。过期时间我设的是 60 秒,因为大部分场景下提示词的结果不需要实时到秒级。这个缓存命中率在实测中能达到 30% 左右,对减少下游压力很有帮助。
提示:缓存要注意内存占用。我设了一个最大条目数,超过之后按 LRU 淘汰。路由器内存有限,不设上限的话缓存会把内存吃光。
6. 实测中的性能表现与踩坑记录
6.1 延迟与吞吐的实测数据
我在 RT-AX88U 上跑了一轮压测,编排器处理一个包含 3 个节点的提示流(意图识别、参数抽取、结果格式化),下游是一个局域网内的推理服务。测试结果如下:
| 指标 | 数值 | 说明 |
|---|---|---|
| 单次编排延迟(无缓存) | 45-60ms | 主要是下游推理耗时 |
| 单次编排延迟(缓存命中) | 2-5ms | 几乎就是内存读取 |
| 并发 10 请求 | 平均 80ms | 有排队但不严重 |
| 并发 50 请求 | 平均 320ms | 开始出现明显排队 |
| 内存占用 | 18-25MB | 稳定 |
| CPU 占用(空闲) | <1% | 几乎不占 |
| CPU 占用(满载) | 40-60% | 单核跑满 |
这组数据说明,路由器上的编排器适合中低并发场景。如果你家里有几十个设备同时发请求,那可能需要考虑把编排器放到更强的硬件上。但对大多数家庭和小型办公场景,这个性能完全够用。
6.2 我踩过的三个典型坑
第一个坑:goroutine 泄漏。我最初的实现里,每个请求都起一个 goroutine 去调用下游,但没有设置超时。结果某个下游服务偶尔卡住,goroutine 就一直挂着,跑了一天之后路由器内存被吃光,直接重启了。修复方法是给每个下游调用都加上context.WithTimeout,超时之后强制返回。
第二个坑:日志写爆 JFFS。我一开始把日志全量写到 JFFS 分区,结果跑了几天之后 JFFS 满了,路由器各种异常。后来改成日志写到/tmp(内存文件系统),并且限制日志文件大小,超过就轮转。JFFS 只存配置和二进制,不存运行时数据。
第三个坑:DNS 解析阻塞。编排器需要解析下游服务的域名,但路由器的 DNS 有时候会抽风,解析一个域名要好几秒。这个问题很隐蔽,因为平时看不出来,只有 DNS 慢的时候才暴露。我的解决方案是在启动时把所有下游域名解析成 IP 缓存起来,后续直接用 IP 调用,定期刷新缓存。
6.3 稳定性优化的几个实用技巧
除了上面三个坑,我还总结了几个让服务更稳的技巧:
- 用 systemd 风格的守护:虽然 Merlin 没有 systemd,但我写了一个简单的守护脚本,定期检查编排器进程是否还在,不在就拉起来。
- 限制并发数:用一个带缓冲的 channel 做信号量,限制同时处理的请求数,避免过载。
- 优雅退出:收到终止信号时,等待正在处理的请求完成再退出,避免请求丢失。
- 配置热加载:配置文件改动后不用重启,编排器定期检查文件修改时间,有变化就重新加载。
这些技巧看起来琐碎,但正是它们决定了服务能不能长期稳定运行。我在实际使用中发现,一个能跑一天的服务和一个能跑一年的服务,差距往往就在这些细节上。
7. 从单机编排到多节点协同的扩展思路
7.1 什么时候需要考虑多节点
单台路由器上的编排器能撑住的并发是有限的。当你发现 CPU 经常跑满、请求排队严重的时候,就该考虑扩展了。扩展的方向有两个:纵向是换更强的硬件,横向是加节点做集群。
横向扩展的思路是:多台路由器(或者路由器 + 小主机)各自跑一个编排器实例,前面加一个简单的负载均衡。但这里有个问题:编排器的状态(比如缓存)是分布式的,怎么同步?我的做法是不做强同步,每个节点维护自己的缓存,允许不一致。因为提示流的缓存本来就是尽力而为的,不一致带来的影响很小。
7.2 节点间通信的轻量方案
节点之间需要通信的场景主要有两个:一是共享一些全局配置,二是做请求转发。我用的是最简单的方案:基于 HTTP 的 gossip。每个节点定期向其他节点广播自己的状态(负载、可用性),收到广播的节点更新自己的节点列表。
type NodeInfo struct { ID string Address string Load float64 LastSeen time.Time }这个方案不追求强一致性,只追求最终一致。在家庭网络这种小规模场景下,完全够用。而且实现简单,不需要引入 etcd、Consul 这类重型组件。
7.3 这个方向还能怎么玩
把 AI 编排器塞进路由器只是边缘智能的一个起点。顺着这个思路,还能做很多有意思的扩展:
- 本地模型缓存:把常用的提示词和结果缓存在路由器上,形成一个“提示词 CDN”。
- 设备联动:编排器直接对接家里的智能设备,实现“一句话控制全屋”。
- 隐私过滤:在数据出局域网之前,编排器先做一遍敏感信息过滤,保护隐私。
- 离线降级:云端 API 不可用的时候,自动切换到本地的小模型或者规则引擎。
这些扩展的共同点是:利用路由器的位置优势,在数据离开局域网之前做尽可能多的事情。这也是边缘计算的核心价值所在。
我个人在实际操作中的体会是,路由器这个平台虽然资源有限,但它的稳定性和常驻特性是其他设备比不了的。一台路由器可以连续运行几个月不出问题,而你的开发机可能每天都要重启。把一些轻量的、常驻的服务放在路由器上,是一种被低估的架构选择。当然,前提是你得接受它的性能边界,别指望它干重活。把编排、缓存、协议转换这些“轻活”交给它,把推理、训练这些“重活”留给更强的节点,各司其职,整个系统反而更稳。