1. 为什么要把 AI 提示流塞进路由器
1.1 一个真实的需求场景
去年年底我在折腾家里的网络时发现一个挺尴尬的事:家里三台笔记本、两台手机、一个平板,加上各种智能音箱和电视盒子,几乎每个设备上都装了至少一个 AI 客户端。问题来了——每个客户端的 API Key 各管各的,提示词模板散落在不同 App 里,切换设备就得重新配一遍。更麻烦的是,有些设备性能弱,跑不动本地模型,但云端调用又得每个 App 单独配置。
我当时就想,能不能把提示流的编排逻辑抽出来,放到一个所有设备都能访问的节点上?这个节点最好是常年开机、功耗低、网络位置又核心的设备。想来想去,家里那台华硕路由器是最合适的——它本来就是 7x24 小时运行,所有流量都经过它,而且 Merlin 固件支持插件扩展,等于给了我一个现成的 Linux 环境。
这就是这个项目的起点:把 AI 提示流的编排能力做成一个跑在路由器上的轻量边缘网关,所有内网设备通过统一的 HTTP 接口调用,提示词模板、模型路由、上下文管理全部集中在这一层完成。
1.2 这个编排器到底解决什么问题
说白了,它干的事就是"中间层"的活。你可以把它理解成一个提示词的路由器:
- 设备 A 发来一个请求,说"帮我总结这段文字",编排器根据预设规则,自动套上总结类的提示词模板,转发给合适的模型接口,拿到结果再返回给设备 A。
- 设备 B 发来一个请求,说"翻译成英文",编排器识别意图,套上翻译模板,可能走另一个模型,返回结果。
- 所有模板、路由规则、API Key 都集中管理,改一处全内网生效。
这样做的好处很直接:设备端零配置、提示词版本统一、模型切换透明、调用日志可追溯。对于家里设备多、又不想每个 App 都折腾一遍的人来说,这个中间层的价值非常明显。
1.3 为什么选华硕路由器 + Merlin
市面上能跑边缘网关的硬件不少,树莓派、NUC、各种小主机都能干这事。但我最终选了华硕路由器 + Merlin 固件,原因有几个:
第一,功耗和常开成本。路由器本来就是常年开着的,额外跑一个轻量服务,功耗增加几乎可以忽略。树莓派虽然也省电,但多一个设备就多一个电源、多一根网线、多一个故障点。
第二,网络位置。路由器是所有内网流量的必经之路,编排器跑在上面,天然就是"边缘"位置。设备不需要知道编排器的 IP,直接用路由器地址就行。
第三,Merlin 的插件生态。Merlin 固件基于华硕官方固件改造,保留了官方驱动的稳定性,同时开放了插件机制。你可以往/jffs分区里放自己的程序,通过services-start脚本开机自启。这等于给了我一个受控的 Linux 环境,虽然没有完整的包管理,但跑一个静态编译的 Go 程序绰绰有余。
第四,Go 的静态编译特性。Go 编译出来的二进制文件不依赖外部运行时,直接扔到路由器上就能跑。交叉编译到arm或arm64架构也很简单,一条命令的事。这对于路由器这种资源受限、又没有完整包管理的环境来说,是最省心的选择。
注意:不是所有华硕路由器都支持 Merlin。一般来说,中高端型号(如 RT-AX88U、RT-AX86U、GT-AX11000 等)支持较好。刷机前务必确认型号在 Merlin 支持列表里,并且备份好原厂固件和配置。
2. 整体架构设计与技术选型
2.1 三层结构:设备层、编排层、模型层
整个系统的架构可以拆成三层:
设备层就是家里各种终端,手机、电脑、平板、智能设备。它们只需要知道一个地址——路由器的 IP 加一个端口,往这个地址发 HTTP 请求就行。请求体里带上"我要干什么"和"内容是什么",不需要关心后面怎么处理。
编排层是跑在路由器上的 Go 程序,也是这个项目的核心。它负责几件事:接收请求、解析意图、匹配提示词模板、组装最终提示词、选择模型接口、转发请求、处理响应、记录日志。这一层是整个系统的"大脑"。
模型层是实际执行推理的地方,可以是云端 API,也可以是内网的另一台机器上跑的本地模型。编排层通过配置好的接口地址和密钥去调用,对设备层完全透明。
这三层之间用 HTTP + JSON 通信,简单、通用、任何设备都能对接。我没有用 gRPC 或者消息队列,因为路由器资源有限,HTTP 的开销已经足够小,而且调试起来直观得多。
2.2 为什么用 Go 而不是 Python 或 Node
这是很多人会问的问题。Python 和 Node 写起来确实快,生态也丰富,但放到路由器上就有问题了:
| 对比项 | Go | Python | Node |
|---|---|---|---|
| 部署方式 | 静态编译单文件 | 需要解释器 + 依赖 | 需要运行时 + node_modules |
| 内存占用 | 低(几 MB 起) | 较高(几十 MB 起) | 中等 |
| 交叉编译 | 一条命令 | 麻烦 | 麻烦 |
| 启动速度 | 毫秒级 | 秒级 | 秒级 |
| 路由器兼容 | 极好 | 差 | 一般 |
路由器上的存储空间通常只有几十 MB 可用(/jffs分区),内存也就 256MB 到 1GB。Python 光解释器加基础库就占掉不少空间,再装依赖很容易撑爆。Go 编译出来的二进制通常只有几 MB 到十几 MB,扔进去就能跑,这是决定性优势。
另外,Go 的标准库自带 HTTP 服务器和客户端,不需要额外依赖。对于这种"接收请求-转发请求"的场景,标准库完全够用,连第三方框架都可以不引入。
2.3 提示流编排的核心模型
编排器的核心是一个"提示流"的概念。一个提示流由几个部分组成:
- 触发条件:什么请求会命中这个流。可以是路径匹配(如
/summarize),也可以是内容关键词匹配。 - 提示词模板:带占位符的模板字符串,比如
请用简洁的语言总结以下内容:{{content}}。 - 模型配置:这个流走哪个模型接口,用什么参数(温度、最大 token 等)。
- 后处理规则:拿到模型返回后怎么处理,比如提取特定字段、格式化输出。
这些配置我用一个 JSON 文件管理,放在/jffs/ai-orchestrator/flows.json。程序启动时加载,运行中可以热重载。这样改配置不需要重新编译,也不需要重启服务。
{ "flows": [ { "name": "summarize", "path": "/summarize", "template": "请用简洁的语言总结以下内容,保留关键信息:\n\n{{content}}", "model": "default", "params": { "temperature": 0.3, "max_tokens": 500 } }, { "name": "translate", "path": "/translate", "template": "请将以下内容翻译成{{target_lang}},只输出翻译结果:\n\n{{content}}", "model": "default", "params": { "temperature": 0.1 } } ] }这个设计的好处是扩展性。想加一个新功能,只需要在 JSON 里加一个 flow,不用改代码。模板里的占位符用{{}}包裹,运行时从请求参数里取值替换。
2.4 轻量边缘网关的定位
有人可能会问,这不就是个 API 网关吗?跟 Nginx 反向代理有什么区别?
区别在于语义层。Nginx 做的是网络层的转发,它不理解请求内容。而这个编排器做的是语义层的编排——它理解"这个请求是要总结",然后主动套上提示词模板,再转发。这是应用层的逻辑,不是网络层的转发。
所以我把它定位成"轻量边缘网关",强调两点:轻量(资源占用小,跑在路由器上)和边缘(部署在网络边缘,靠近设备)。它不是要替代 Nginx,而是在 Nginx 之上再加一层语义编排能力。
3. 路由器端环境准备与 Go 程序交叉编译
3.1 Merlin 固件下的可用环境探查
在动手写代码之前,得先摸清楚路由器上有什么。SSH 登录进去之后,我习惯先跑几个命令看看环境:
uname -a cat /proc/cpuinfo df -h free -m从uname -a能看到内核版本和架构。华硕路由器大多是 ARM 架构,具体是armv7l还是aarch64要看型号。/proc/cpuinfo能看到 CPU 核心数和型号。df -h重点看/jffs分区有多大,这决定了你能放多少东西。free -m看内存,判断能跑多重的服务。
Merlin 固件通常自带entware的支持,但我不建议依赖它。Entware 需要额外安装,而且包管理在路由器上跑起来比较重。对于我们的场景,一个静态编译的 Go 二进制就够了,不需要包管理。
实操心得:Merlin 的
/jffs分区默认可能只有几 MB 到几十 MB,取决于型号和固件版本。如果空间不够,可以在路由器上插一个 U 盘,把程序和数据放到 U 盘上,通过services-start脚本挂载后启动。U 盘建议格式化成 ext4,兼容性和性能都比 FAT32 好。
3.2 Go 交叉编译到 ARM 架构
Go 的交叉编译非常直接。假设你的开发机是 x86_64 的 Linux 或 macOS,要编译到路由器的 ARM 架构,只需要设置两个环境变量:
# 对于 32 位 ARM(armv7l) GOOS=linux GOARCH=arm GOARM=7 go build -o ai-orchestrator-arm7 . # 对于 64 位 ARM(aarch64) GOOS=linux GOARCH=arm64 go build -o ai-orchestrator-arm64 .编译出来的二进制文件可以直接scp到路由器上。为了减小体积,可以在编译时加上-ldflags "-s -w"去掉调试信息:
GOOS=linux GOARCH=arm GOARM=7 go build -ldflags "-s -w" -o ai-orchestrator .这样编译出来的文件通常只有 5-8MB,对于路由器来说完全可以接受。
怎么确认该用哪个架构?在路由器上跑uname -m:
- 输出
armv7l→ 用GOARCH=arm GOARM=7 - 输出
aarch64→ 用GOARCH=arm64
选错了架构,程序跑不起来,会报cannot execute binary file或者No such file or directory(后者是因为动态链接器找不到,静态编译可以避免)。
3.3 目录规划与开机自启脚本
在路由器上,我建议按这个结构组织文件:
/jffs/ai-orchestrator/ ├── ai-orchestrator # 编译好的二进制 ├── flows.json # 提示流配置 ├── config.json # 模型接口配置(含密钥) ├── logs/ # 日志目录 └── start.sh # 启动脚本start.sh的内容大概是这样:
#!/bin/sh cd /jffs/ai-orchestrator ./ai-orchestrator -config config.json -flows flows.json >> logs/orchestrator.log 2>&1 &然后通过 Merlin 的services-start机制实现开机自启。在/jffs/scripts/目录下创建services-start文件:
#!/bin/sh /jffs/ai-orchestrator/start.sh记得给脚本加执行权限:
chmod +x /jffs/ai-orchestrator/start.sh chmod +x /jffs/scripts/services-start注意:Merlin 的
services-start脚本在系统服务启动后执行,但此时网络可能还没完全就绪。如果你的程序启动时需要立即访问外网,建议在脚本里加一个短暂的等待,或者让程序自身具备重试逻辑。我一般会在程序里做指数退避重试,比在脚本里sleep更可靠。
3.4 资源限制与守护策略
路由器资源有限,程序跑起来之后要防止它失控。几个关键点:
内存限制。Go 程序默认的 GC 策略在内存受限环境下可能不够激进。可以通过GOGC环境变量调整,比如GOGC=50让 GC 更频繁地运行,降低内存峰值。在start.sh里设置:
export GOGC=50进程守护。程序万一崩了要能自动重启。Merlin 没有 systemd,但可以用一个简单的守护脚本,或者用cru(cron)定时检查进程是否存在。我倾向于在程序内部做好错误处理,让它尽量不崩,同时用一个轻量的守护脚本兜底。
日志轮转。日志不能无限增长,否则会把/jffs撑爆。程序内部做日志轮转,或者用logrotate(如果路由器上有的话)。我一般让程序自己控制日志文件大小,超过阈值就切分。
4. 提示流编排器的核心代码实现
4.1 HTTP 服务与路由设计
编排器的 HTTP 服务用 Go 标准库的net/http就够了。路由设计上,我采用"路径即功能"的方式,每个提示流对应一个路径:
package main import ( "encoding/json" "io" "log" "net/http" "os" ) type Flow struct { Name string `json:"name"` Path string `json:"path"` Template string `json:"template"` Model string `json:"model"` Params map[string]interface{} `json:"params"` } type FlowConfig struct { Flows []Flow `json:"flows"` } var flows map[string]Flow func loadFlows(path string) error { data, err := os.ReadFile(path) if err != nil { return err } var cfg FlowConfig if err := json.Unmarshal(data, &cfg); err != nil { return err } flows = make(map[string]Flow) for _, f := range cfg.Flows { flows[f.Path] = f } return nil } func handleRequest(w http.ResponseWriter, r *http.Request) { flow, ok := flows[r.URL.Path] if !ok { http.NotFound(w, r) return } body, _ := io.ReadAll(r.Body) var reqData map[string]string json.Unmarshal(body, &reqData) // 模板替换逻辑 prompt := renderTemplate(flow.Template, reqData) // 调用模型 result, err := callModel(flow, prompt) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"result": result}) } func main() { if err := loadFlows("/jffs/ai-orchestrator/flows.json"); err != nil { log.Fatal(err) } http.HandleFunc("/", handleRequest) log.Println("AI Orchestrator listening on :8787") log.Fatal(http.ListenAndServe(":8787", nil)) }这段代码是骨架,实际实现里还要加上配置加载、模板渲染、模型调用、错误处理、日志记录等。但核心思路就是这样:加载配置 → 注册路由 → 处理请求 → 渲染模板 → 调用模型 → 返回结果。
端口我选了8787,避开路由器上常用的端口(80、443、8080 等),减少冲突可能。
4.2 提示词模板引擎的轻量实现
模板渲染不需要引入复杂的模板引擎,Go 标准库的text/template就够用。但为了更轻量,我实际用的是自己写的一个简单替换函数:
func renderTemplate(tmpl string, data map[string]string) string { result := tmpl for key, value := range data { placeholder := "{{" + key + "}}" result = strings.ReplaceAll(result, placeholder, value) } return result }这个实现很简单,但有几个要注意的点:
第一,占位符冲突。如果内容里本身包含{{这样的字符,可能会被误替换。实际使用中,我会在替换前对内容做转义,或者用更独特的占位符格式,比如{{@content@}}。
第二,缺失参数处理。如果模板里有{{target_lang}}但请求里没传,替换后占位符会原样保留。我一般会在替换后检查是否还有未替换的占位符,有的话返回错误提示。
第三,性能。对于路由器这种低性能设备,字符串替换的开销可以忽略不计。但如果模板很大、请求很频繁,可以考虑预编译模板。不过在实际使用中,这个开销远小于网络请求本身,不用过度优化。
4.3 模型接口调用与超时控制
调用模型接口是编排器最核心也最容易出问题的环节。我用 Go 的http.Client来实现,关键是超时控制和错误重试:
var httpClient = &http.Client{ Timeout: 60 * time.Second, } func callModel(flow Flow, prompt string) (string, error) { reqBody := map[string]interface{}{ "model": flow.Model, "messages": []map[string]string{ {"role": "user", "content": prompt}, }, } for k, v := range flow.Params { reqBody[k] = v } jsonData, _ := json.Marshal(reqBody) var lastErr error for attempt := 0; attempt < 3; attempt++ { req, _ := http.NewRequest("POST", modelEndpoint, bytes.NewReader(jsonData)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+apiKey) resp, err := httpClient.Do(req) if err != nil { lastErr = err time.Sleep(time.Duration(1<<attempt) * time.Second) continue } defer resp.Body.Close() if resp.StatusCode != 200 { body, _ := io.ReadAll(resp.Body) lastErr = fmt.Errorf("status %d: %s", resp.StatusCode, string(body)) if resp.StatusCode >= 500 { time.Sleep(time.Duration(1<<attempt) * time.Second) continue } return "", lastErr } var result map[string]interface{} json.NewDecoder(resp.Body).Decode(&result) return extractContent(result), nil } return "", lastErr }这里有几个设计决策值得说明:
超时设 60 秒。模型推理有时候比较慢,特别是长文本。60 秒是个折中值,太短容易误杀,太长会占住连接。实际使用中可以根据模型和任务类型调整。
重试 3 次,指数退避。网络抖动或者服务端临时故障时,重试能提高成功率。指数退避(1s、2s、4s)避免短时间内大量重试打爆服务端。
只对 5xx 重试。4xx 错误通常是请求本身有问题(比如参数错误、认证失败),重试没意义,直接返回错误。
响应解析要健壮。不同模型接口的返回格式可能不同,extractContent函数要根据实际接口做适配。我一般会做多层判断,兼容常见的几种格式。
4.4 配置热重载与日志落盘
配置热重载是个实用功能。改完flows.json不想重启服务,可以通过一个特殊接口触发重载:
http.HandleFunc("/reload", func(w http.ResponseWriter, r *http.Request) { if err := loadFlows("/jffs/ai-orchestrator/flows.json"); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Write([]byte("reloaded")) })这个接口要加访问控制,不能随便让人调用。我一般会检查请求来源 IP,只允许内网特定地址访问。
日志落盘要注意文件大小控制。我实现了一个简单的轮转逻辑:日志文件超过 5MB 就重命名归档,新建一个继续写。归档文件保留最近 3 个,更早的删除。这样既能追溯问题,又不会撑爆存储。
func rotateLog(logPath string, maxSize int64) { info, err := os.Stat(logPath) if err != nil || info.Size() < maxSize { return } os.Rename(logPath, logPath+".1") // 清理更早的归档 os.Remove(logPath + ".3") os.Rename(logPath+".2", logPath+".3") os.Rename(logPath+".1", logPath+".2") }实操心得:路由器上的存储写入寿命有限,日志不要写得太频繁。我一般只记录关键事件(请求开始、请求结束、错误),不记录每个请求的完整内容。如果确实需要详细日志,可以临时开启,排查完就关掉。
5. 常见问题排查与实战避坑指南
5.1 程序跑不起来?先查这几个地方
在路由器上跑 Go 程序,最常见的问题就那么几个,按顺序排查基本都能解决:
问题一:cannot execute binary file。这是架构不匹配。在路由器上跑uname -m确认架构,重新交叉编译。注意armv7l和aarch64是不同的,别搞混。
问题二:No such file or directory。这个报错很迷惑,文件明明在,为什么说找不到?通常是动态链接器的问题。如果你编译时没有静态链接,程序会依赖系统的 libc,而路由器上的 libc 可能不兼容。解决办法是编译时加CGO_ENABLED=0:
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags "-s -w" -o ai-orchestrator .问题三:permission denied。文件没有执行权限。chmod +x ai-orchestrator解决。
问题四:端口被占用。换个端口,或者查一下谁占用了。路由器上netstat -tlnp可以看端口占用情况。
问题五:内存不足被 kill。看dmesg有没有 OOM 相关记录。如果有,调小GOGC,或者优化程序内存使用。
5.2 模型调用超时与网络抖动处理
路由器作为边缘节点,网络环境可能不如机房稳定。模型调用超时是家常便饭,处理不好会严重影响体验。
我的策略是分级超时 + 异步补偿:
- 短任务(如翻译、简单问答)超时设 30 秒。
- 长任务(如长文总结)超时设 120 秒。
- 超时后不直接报错,而是返回一个"任务已提交"的响应,后台继续处理,处理完通过回调或者轮询获取结果。
这个异步模式对于路由器这种可能网络不稳定的环境特别有用。用户发起请求后不用一直等着,可以先干别的,回头再来取结果。
实现上,我用一个简单的内存队列 + goroutine 池。任务提交后返回一个 task ID,用户用这个 ID 查询结果。结果保留一段时间(比如 10 分钟),过期清理。
5.3 提示词模板踩过的坑
模板这块我踩过几个坑,分享出来避免大家重复:
坑一:占位符和内容冲突。有一次用户发来的内容里正好包含{{content}}这个字符串,结果替换后整个提示词乱套了。解决办法是替换前先对内容做转义,把{{替换成{ {之类的安全形式。
坑二:模板里的换行丢失。JSON 里的\n在解析后是真正的换行,但如果模板是从别的地方复制来的,可能带着字面的\n两个字符。要在加载配置后做一次规范化处理。
坑三:参数类型不匹配。模板替换要求所有值都是字符串,但请求里可能传来数字或布尔值。我一般统一转成字符串再替换,避免类型错误。
坑四:模板太长导致 token 超限。有些模板写得太啰嗦,加上用户内容后超过了模型的上下文限制。解决办法是在模板设计时就控制长度,或者在程序里做 token 估算,超限时截断或报错。
5.4 路由器存储与内存的边界测试
在路由器上跑服务,必须清楚资源的边界在哪里。我做过几组测试,数据供参考:
| 资源项 | 典型值(中端华硕路由器) | 注意事项 |
|---|---|---|
| /jffs 可用空间 | 20-60 MB | 程序 + 配置 + 日志要控制在这个范围内 |
| 可用内存 | 100-400 MB | Go 程序常驻内存约 10-30 MB |
| CPU | 双核或四核 ARM | 单次请求处理耗时通常在毫秒级 |
| 网络 | 千兆有线 / WiFi 6 | 模型调用走外网,延迟取决于宽带质量 |
从测试结果看,跑一个轻量编排器完全没问题。但如果要跑本地模型推理,那就别想了——路由器那点算力跑不动。编排器的定位就是"调度",不是"计算"。
注意:不同型号的路由器资源差异很大。高端型号(如 GT-AX11000)内存可能到 1GB,低端型号可能只有 256MB。部署前务必确认自己设备的实际资源。
5.5 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 程序无法启动 | 架构不匹配 | uname -m对比编译目标 | 重新交叉编译 |
| 启动后立即退出 | 动态链接问题 | file命令查看二进制类型 | 加CGO_ENABLED=0静态编译 |
| 端口无法访问 | 防火墙拦截 | iptables -L查看规则 | 开放对应端口 |
| 模型调用失败 | 网络不通或密钥错误 | curl手动测试接口 | 检查网络和配置 |
| 内存持续增长 | 内存泄漏 | 观察top中 RSS 变化 | 检查 goroutine 泄漏 |
| 日志文件过大 | 未做轮转 | ls -lh logs/ | 实现日志轮转 |
| 开机不自启 | 脚本权限或路径错误 | 检查services-start | 修正权限和路径 |
| 响应特别慢 | 模型端延迟高 | 分段计时排查 | 优化超时和重试策略 |
这张表基本覆盖了我遇到过的所有问题。实际排查时,先看日志,再看进程状态,最后查网络,这个顺序能解决 90% 的问题。
6. 从单机到多节点的扩展思路
6.1 多路由器组网时的编排策略
如果你家里不止一台路由器(比如主路由 + AP 模式),或者想把编排能力扩展到多个节点,就需要考虑分布式的问题。
最简单的方案是主从模式:主路由跑编排器,其他节点只做网络转发。所有设备统一访问主路由的编排接口。这个方案实现简单,但主路由是单点,挂了就全挂。
进阶方案是多节点对等:每个节点都跑编排器,配置同步。设备访问任意节点都能得到服务。这个方案需要解决配置同步和状态一致性问题,复杂度高不少。
我的建议是:家庭场景用主从模式就够了。单点故障的概率其实不高,路由器本身很稳定。真要做高可用,不如把编排器跑在一台独立的小主机上,路由器只做网络。
6.2 与本地模型服务的协同
编排器本身不跑模型,但可以调度本地模型服务。如果你在内网有一台性能较强的机器跑着本地模型(比如通过常见的推理框架部署),编排器可以把请求路由到这台机器,实现"云端 + 本地"的混合调度。
路由策略可以基于几个维度:
- 任务类型:简单任务走本地,复杂任务走云端。
- 内容敏感度:敏感内容走本地,不敏感走云端。
- 成本考虑:本地免费,云端按量计费,优先本地。
- 可用性:本地不可用时自动降级到云端。
这个混合调度是编排器最有价值的能力之一。它让"用哪个模型"变成一个可配置的策略问题,而不是硬编码在设备端。
6.3 监控与可观测性建设
跑在路由器上的服务,监控不能太重,但基本的可观测性要有。我一般暴露几个关键指标:
- 请求总数:累计处理了多少请求。
- 成功率:成功响应占比。
- 平均延迟:从收到请求到返回响应的平均耗时。
- 模型调用分布:各模型接口的调用次数和失败率。
这些指标通过一个/metrics接口暴露,格式用简单的 JSON 或者 Prometheus 文本格式。然后用一个轻量的采集脚本定期拉取,存到本地或者推送到监控系统。
日志方面,我坚持结构化日志:每条日志是一个 JSON 对象,包含时间戳、级别、事件类型、关键字段。这样后续分析起来方便,也容易接入日志系统。
{"time":"2025-01-15T10:30:00Z","level":"info","event":"request","flow":"summarize","duration_ms":1250,"status":"ok"}这种格式看起来比纯文本啰嗦,但机器解析起来方便得多。在路由器这种资源受限的环境,日志量本来就不大,结构化带来的额外开销可以接受。
6.4 安全边界与访问控制
把 AI 能力暴露在内网,安全问题不能忽视。几个基本措施:
第一,只监听内网地址。编排器绑定在路由器的内网 IP 上,不要绑定0.0.0.0,避免意外暴露到外网。
第二,加一个简单的 Token 认证。所有请求带一个预共享的 Token,程序校验通过才处理。Token 放在配置文件里,定期更换。
第三,限制请求频率。防止某个设备疯狂调用把资源耗尽。简单的令牌桶算法就能实现。
第四,敏感配置加密存储。API Key 这类敏感信息不要明文放在配置文件里。可以用一个简单的加密方案,启动时输入密码解密。
第五,日志脱敏。日志里不要记录完整的请求内容和 API Key。我一般只记录请求的元数据(路径、耗时、状态),内容只记录长度不记录具体值。
实操心得:家庭内网的安全威胁主要来自内部(比如某个设备被入侵)而不是外部。所以访问控制的重点是"限制内网设备的权限",而不是"防外网攻击"。Token 认证 + 频率限制这两条做好,基本就够用了。
7. 我在这套方案上的一些真实体会
这套东西我从去年年底开始折腾,中间迭代了好几版,现在稳定跑在家里那台 RT-AX88U 上。说几个真实的体会。
第一,路由器跑服务的稳定性超出预期。我原本担心路由器资源紧张会经常出问题,实际跑下来,连续运行几个月没重启过。Go 程序的内存占用很稳定,没有明显泄漏。Merlin 固件本身也很稳,没出现过因为跑插件导致的路由器崩溃。
第二,编排层的价值在于"统一"。最开始我是每个设备单独配 AI 客户端,后来发现改一个提示词要改好几处,特别麻烦。集中到编排层之后,改一处全生效,这个体验提升是实实在在的。
第三,不要过度设计。我一开始想搞得很复杂,支持各种高级功能,后来发现大部分用不上。最后砍到只剩"模板替换 + 模型转发"这两个核心功能,反而更好用、更稳定。在资源受限的环境里,简单就是美。
第四,日志和监控要适度。我一开始日志记得很详细,结果/jffs很快就被撑满了。后来改成只记关键事件,问题反而更容易定位——因为噪音少了。
第五,备份配置很重要。路由器固件升级或者重置会清掉/jffs里的东西。我现在养成了习惯,每次改完配置就备份一份到本地。恢复的时候直接传回去,几分钟就能重建环境。
最后分享一个小技巧:如果你不确定某个功能该不该加,就先不加,用一段时间看看到底缺不缺。大部分"我觉得可能需要"的功能,实际上从来没用过。真正需要的功能,用着用着自然就发现缺了,那时候再加也不迟。这套编排器现在跑得很稳,我基本不用管它,它就在那里安安静静地干活。