ARTICLE DETAIL

资讯详情

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

华硕路由器改造AI提示流编排器:Go语言边缘计算实战

华硕路由器改造AI提示流编排器:Go语言边缘计算实战 1. 为什么要把 AI 提示流塞进路由器1.1 一个真实的需求场景家里那台华硕路由器常年 7×24 小时开机功耗不到 10W放在弱电箱里默默干活。某天我盯着它突然冒出一个念头这东西有 CPU、有内存、有存储、有网络还一直在线为什么不能让它顺便跑点 AI 相关的轻量任务具体痛点是这样的我平时写东西、整理资料、做翻译经常要调用各种大模型的 API。每次都得开电脑、开终端、敲命令或者打开某个网页工具。有时候只是想快速把一段中文转成英文或者把一篇文章摘要成三句话却要经历开机-打开工具-粘贴-等待-复制这一整套流程。更麻烦的是我手头有好几个不同的模型服务有的擅长翻译有的擅长摘要有的擅长代码每次都要手动切换提示词也得反复调整。于是就有了这个项目的核心想法把路由器改造成一个轻量级的 AI 提示流编排器。它常驻在路由器上对外暴露一个简单的 HTTP 接口我手机、电脑、平板上的任何工具都能调用。它内部负责管理多个 AI 引擎的配置、提示词模板、调用链路把提示流这件事从我的电脑里解放出来变成一个随时可用的边缘服务。这个项目属于开源系列的第 12 篇前面已经折腾过不少路由器上的小工具这次算是把 AI 能力和边缘计算结合起来的一次尝试。适合谁看如果你手里有一台支持 Merlin 固件的华硕路由器懂一点 Linux 基础想学 Go 语言写点实用的东西或者单纯对边缘 AI 网关这个概念好奇那这篇内容应该对你有用。1.2 为什么选 Merlin 固件而不是原厂固件华硕路由器的原厂固件功能其实不少但它有一个致命问题你没法方便地往里面塞自己的程序。原厂固件虽然基于 Linux但做了大量裁剪和锁定没有包管理器没有完整的 shell 环境想跑个自定义二进制文件非常折腾。Merlin 固件Asuswrt-Merlin是社区在原厂固件基础上做的增强版保留了原厂的稳定性和硬件兼容性同时开放了很多实用功能。最关键的是它支持Entware和JFFS 自定义脚本这意味着你可以像在普通 Linux 上一样安装软件包、写启动脚本、跑后台服务。我实测下来Merlin 固件在 RT-AX88U、RT-AC86U 这几款主流型号上都很稳。JFFS 分区通常有几十 MB 的可用空间对于我们要跑的 Go 编译出来的静态二进制文件来说完全够用。Entware 则提供了 opkg 包管理器需要什么依赖直接装省去了交叉编译的麻烦。注意不是所有华硕路由器都支持 Merlin 固件购买或刷机前务必去 Merlin 官网查一下你的型号是否在支持列表里。另外刷机有风险建议先备份原厂固件和配置。1.3 为什么用 Go 而不是 Python 或 Shell这是整个项目最关键的选型决策我踩过坑之后才坚定选了 Go。最开始我用 Shell 脚本写了个原型调用 curl 发 HTTP 请求用 jq 解析 JSON。简单场景能跑但一旦涉及多步骤的提示流编排、并发请求、错误重试Shell 脚本就变得极其难维护。而且路由器上的 Shell 环境是 BusyBox 裁剪版很多命令的参数和标准 Linux 不一样调试起来很痛苦。后来试了 Python路由器上通过 Entware 装 Python3 是可行的但问题在于Python 解释器本身加上依赖库占用空间轻松超过 50MB而且启动慢、内存占用高。路由器那点内存通常 256MB 到 512MB经不起这么折腾。Go 的优势就体现出来了静态编译编译出来的二进制文件不依赖任何运行时直接扔到路由器上就能跑。体积可控一个功能完整的 HTTP 服务编译后大概 8-15MB用 UPX 压缩后能到 5MB 左右。内存占用低常驻内存通常只有十几 MB对路由器毫无压力。并发模型好goroutine 处理并发请求非常自然编排多个 AI 调用时特别顺手。交叉编译简单在电脑上一条命令就能编译出路由器架构的二进制文件。这里补充一个交叉编译的关键点。华硕路由器大多是 ARM 架构具体又分 armv7 和 aarch64。你需要先确认路由器的架构uname -m如果是armv7l编译时用GOARCHarm GOARM7如果是aarch64用GOARCHarm64。我用的 RT-AX88U 是 aarch64编译命令如下CGO_ENABLED0 GOOSlinux GOARCHarm64 go build -ldflags-s -w -o ai-flow-router main.goCGO_ENABLED0确保纯静态编译-ldflags-s -w去掉调试信息减小体积。这一步很关键我第一次编译忘了加CGO_ENABLED0结果二进制文件在路由器上跑不起来报动态链接库找不到的错误排查了半天。2. 整体架构设计与核心模块拆解2.1 提示流编排器的核心概念在动手写代码之前得先把提示流编排这件事想清楚。所谓提示流本质上就是把用户的输入经过一系列处理步骤最终变成对 AI 引擎的调用再把结果返回给用户。这个一系列处理步骤就是编排的核心。我把它抽象成三个层次第一层是入口层负责接收外部请求。可能是一个 HTTP 请求也可能是一个定时任务甚至是一个文件变化触发的动作。入口层要做的事情很简单解析请求、提取参数、决定走哪条流程。第二层是编排层这是整个系统的核心。它根据配置好的流程定义依次执行各个节点。节点可以是调用某个 AI 引擎、对文本做某种处理、条件判断、循环等等。每个节点的输出可以作为下一个节点的输入形成一条数据流水线。第三层是引擎层负责实际调用各种 AI 服务。每个引擎有自己的 API 地址、认证方式、请求格式、响应格式。引擎层把这些差异封装起来对上提供统一的调用接口。这样分层的好处是新增一个 AI 引擎只需要在引擎层加一个适配器新增一种流程只需要在编排层加一个配置互不影响。我后来加了好几个不同的模型服务都是只改引擎层编排逻辑一行没动。2.2 目录结构与文件布局路由器上的存储空间有限所以目录结构要尽量精简。我在 JFFS 分区下建了这样一个布局/jffs/ai-flow/ ├── bin/ │ └── ai-flow-router # 编译好的二进制文件 ├── conf/ │ ├── config.yaml # 主配置监听端口、日志级别等 │ ├── engines.yaml # AI 引擎配置 │ └── flows.yaml # 提示流定义 ├── data/ │ ├── logs/ # 日志目录 │ └── cache/ # 缓存目录 └── scripts/ ├── start.sh # 启动脚本 └── stop.sh # 停止脚本这个布局的逻辑是二进制和配置分离数据和代码分离。这样升级程序时只需要替换bin下的文件配置和数据都不受影响。日志和缓存单独放data目录方便清理和监控。配置文件用 YAML 格式因为可读性好手写不容易出错。Go 里解析 YAML 用gopkg.in/yaml.v3这个库成熟稳定。2.3 引擎配置的设计引擎配置是整个系统的通讯录记录了每个 AI 服务的连接信息。我设计的结构是这样的engines: - name: translator type: openai-compatible endpoint: https://api.example.com/v1/chat/completions api_key: ${TRANSLATOR_KEY} model: gpt-4o-mini timeout: 30 max_retries: 2 - name: summarizer type: openai-compatible endpoint: https://api.example.com/v1/chat/completions api_key: ${SUMMARIZER_KEY} model: claude-3-haiku timeout: 60 max_retries: 3这里有几个设计考量。type字段决定了用哪个适配器来调用目前主流服务大多兼容 OpenAI 的接口格式所以一个openai-compatible适配器就能覆盖大部分场景。api_key用环境变量占位符避免密钥明文写在配置文件里启动脚本里 export 一下就行。timeout和max_retries是必须的。路由器网络环境不一定稳定AI 服务偶尔也会抽风没有超时和重试机制的话一个卡住的请求能把整个服务拖垮。我一般把超时设成 30 到 60 秒重试 2 到 3 次重试间隔用指数退避。实操心得API 密钥千万不要直接写在配置文件里然后提交到 Git。我用的是启动脚本里 export 环境变量配置文件里只写${VAR_NAME}程序启动时读取环境变量替换。这样即使配置文件泄露密钥也是安全的。2.4 提示流定义的设计提示流定义是编排层的核心它描述了一条完整的处理链路。我设计的格式是这样的flows: - name: translate-and-summarize description: 先翻译成英文再生成摘要 steps: - id: translate engine: translator prompt: | 把下面的内容翻译成英文只输出翻译结果 {{.input}} output: translated_text - id: summarize engine: summarizer prompt: | 用三句话总结下面的内容 {{.translated_text}} output: final_result每个 step 有唯一的id指定用哪个engine定义prompt模板以及把结果存到哪个变量里。模板里用{{.变量名}}引用之前步骤的输出或者引用原始输入{{.input}}。这个设计的关键在于变量传递机制。每个步骤执行完后结果会存到一个上下文context里后续步骤可以从上下文里取数据。这样就能实现步骤之间的数据流动形成真正的流。我一开始想用更复杂的 DAG有向无环图来定义流程支持并行和条件分支。后来发现对于路由器这种场景线性流程已经能覆盖 90% 的需求过度设计反而增加复杂度和出错概率。所以最终版本就是简单的顺序执行需要条件判断的话在 prompt 里让 AI 自己处理。3. 核心代码实现与关键细节3.1 HTTP 服务的搭建服务入口用 Go 标准库的net/http就够了不需要引入 Web 框架。路由器上跑的东西越简单越可靠。package main import ( encoding/json log net/http time ) type Server struct { config *Config engine *EngineManager flow *FlowManager } func (s *Server) handleRunFlow(w http.ResponseWriter, r *http.Request) { if r.Method ! http.MethodPost { http.Error(w, method not allowed, http.StatusMethodNotAllowed) return } var req RunFlowRequest if err : json.NewDecoder(r.Body).Decode(req); err ! nil { http.Error(w, invalid request body, http.StatusBadRequest) return } ctx, cancel : context.WithTimeout(r.Context(), 120*time.Second) defer cancel() result, err : s.flow.Execute(ctx, req.FlowName, req.Input) if err ! nil { log.Printf(flow execute error: %v, err) http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set(Content-Type, application/json) json.NewEncoder(w).Encode(map[string]string{result: result}) }这里有几个细节值得说。请求体大小要限制防止有人发个超大请求把路由器内存打爆。我在http.Server里设置了MaxHeaderBytes在 handler 里用http.MaxBytesReader限制 body 大小。超时控制用 context这是 Go 里处理超时的标准做法。整个流程的超时要大于单个引擎调用的超时因为一条流可能包含多个步骤。我一般把流程超时设成 120 秒单个引擎超时 30 到 60 秒。错误处理要区分类型。参数错误返回 400内部错误返回 500超时返回 504。这样调用方能根据状态码判断问题出在哪。3.2 引擎适配器的实现引擎适配器是连接编排层和实际 AI 服务的桥梁。核心是一个统一的接口type Engine interface { Call(ctx context.Context, prompt string) (string, error) } type OpenAICompatibleEngine struct { name string endpoint string apiKey string model string timeout time.Duration maxRetries int client *http.Client } func (e *OpenAICompatibleEngine) Call(ctx context.Context, prompt string) (string, error) { reqBody : map[string]interface{}{ model: e.model, messages: []map[string]string{ {role: user, content: prompt}, }, } bodyBytes, _ : json.Marshal(reqBody) var lastErr error for attempt : 0; attempt e.maxRetries; attempt { if attempt 0 { backoff : time.Duration(1uint(attempt-1)) * time.Second select { case -time.After(backoff): case -ctx.Done(): return , ctx.Err() } } req, err : http.NewRequestWithContext(ctx, POST, e.endpoint, bytes.NewReader(bodyBytes)) if err ! nil { return , err } req.Header.Set(Content-Type, application/json) req.Header.Set(Authorization, Bearer e.apiKey) resp, err : e.client.Do(req) if err ! nil { lastErr err continue } if resp.StatusCode 500 { resp.Body.Close() lastErr fmt.Errorf(server error: %d, resp.StatusCode) continue } var result struct { Choices []struct { Message struct { Content string json:content } json:message } json:choices } if err : json.NewDecoder(resp.Body).Decode(result); err ! nil { resp.Body.Close() lastErr err continue } resp.Body.Close() if len(result.Choices) 0 { return , fmt.Errorf(empty response) } return result.Choices[0].Message.Content, nil } return , fmt.Errorf(all retries failed: %w, lastErr) }这段代码里有几个我踩过坑才加上的东西。指数退避重试第一次失败等 1 秒第二次等 2 秒第三次等 4 秒。这样既能应对临时故障又不会在服务真的挂了的时候疯狂重试把对方打爆。只对 5xx 和网络错误重试4xx 错误比如认证失败、参数错误重试多少次都没用直接返回。这个判断很重要我一开始没区分结果密钥配错的时候程序疯狂重试日志刷屏。响应体及时关闭resp.Body.Close()一定要调用否则连接不会释放跑久了会耗尽文件描述符。这个坑我在路由器上踩过服务跑了两天突然不响应了一查是 fd 泄漏。3.3 提示流执行引擎流程执行引擎负责按顺序执行各个步骤管理上下文变量。核心逻辑是这样的type FlowManager struct { flows map[string]*Flow engines *EngineManager } func (fm *FlowManager) Execute(ctx context.Context, flowName, input string) (string, error) { flow, ok : fm.flows[flowName] if !ok { return , fmt.Errorf(flow not found: %s, flowName) } vars : map[string]string{input: input} for _, step : range flow.Steps { engine, err : fm.engines.Get(step.Engine) if err ! nil { return , fmt.Errorf(step %s: %w, step.ID, err) } prompt, err : renderTemplate(step.Prompt, vars) if err ! nil { return , fmt.Errorf(step %s render: %w, step.ID, err) } result, err : engine.Call(ctx, prompt) if err ! nil { return , fmt.Errorf(step %s call: %w, step.ID, err) } vars[step.Output] result } return vars[flow.Steps[len(flow.Steps)-1].Output], nil }模板渲染用 Go 标准库的text/template简单可靠。{{.input}}这种语法直接就能用不需要自己写解析器。这里有个细节最终返回的是最后一个步骤的输出变量。如果流程定义里最后一个步骤的 output 名字写错了就会返回空字符串。所以我在加载配置的时候会做校验确保每个步骤的 output 名字合法且不重复。3.4 配置热加载路由器上的服务不能动不动就重启所以配置热加载是必须的。我实现了一个简单的文件监听机制func (c *ConfigManager) Watch(ctx context.Context) { ticker : time.NewTicker(30 * time.Second) defer ticker.Stop() var lastMod time.Time for { select { case -ctx.Done(): return case -ticker.C: info, err : os.Stat(c.configPath) if err ! nil { continue } if info.ModTime().After(lastMod) { lastMod info.ModTime() if err : c.reload(); err ! nil { log.Printf(config reload failed: %v, err) } else { log.Printf(config reloaded successfully) } } } } }用轮询而不是 inotify是因为路由器上的文件系统对 inotify 支持不一定完整轮询虽然笨但可靠。30 秒检查一次对路由器来说开销可以忽略。重载失败要保留旧配置不能因为新配置有语法错误就让整个服务挂掉。这个逻辑在reload()里实现先解析到临时变量解析成功才替换当前配置。4. 部署实战从编译到开机自启4.1 交叉编译与文件传输在电脑上编译好二进制文件后需要传到路由器上。最方便的方式是用 scpscp ai-flow-router admin192.168.1.1:/jffs/ai-flow/bin/前提是路由器开启了 SSH。Merlin 固件在系统管理-系统设置里可以开启 SSH 访问建议只开 LAN 访问不要暴露到公网。传输完成后登录路由器设置权限ssh admin192.168.1.1 chmod x /jffs/ai-flow/bin/ai-flow-router注意JFFS 分区默认可能没有挂载或者被禁用。在 Merlin 固件的系统管理-系统设置里把启用 JFFS 自定义脚本和格式化 JFFS 分区打开重启后 JFFS 才会可用。这一步不做的话你往 /jffs 写的东西重启就没了。4.2 启动脚本的编写启动脚本要处理几件事设置环境变量、检查是否已在运行、后台启动、记录 PID。#!/bin/sh APP_DIR/jffs/ai-flow BIN$APP_DIR/bin/ai-flow-router PID_FILE$APP_DIR/data/ai-flow.pid LOG_FILE$APP_DIR/data/logs/ai-flow.log export TRANSLATOR_KEYyour-key-here export SUMMARIZER_KEYyour-key-here start() { if [ -f $PID_FILE ] kill -0 $(cat $PID_FILE) 2/dev/null; then echo already running return 0 fi mkdir -p $APP_DIR/data/logs nohup $BIN -config $APP_DIR/conf/config.yaml $LOG_FILE 21 echo $! $PID_FILE echo started with pid $(cat $PID_FILE) } stop() { if [ -f $PID_FILE ]; then kill $(cat $PID_FILE) 2/dev/null rm -f $PID_FILE echo stopped fi } case $1 in start) start ;; stop) stop ;; restart) stop; sleep 2; start ;; *) echo usage: $0 {start|stop|restart} ;; esac这个脚本用的是 BusyBox 的 sh不能用 bash 特有的语法。nohup和组合让程序在后台运行输出重定向到日志文件。4.3 开机自启的配置Merlin 固件支持在 JFFS 里放启动脚本路径是/jffs/scripts/services-start。这个脚本在系统服务启动后执行#!/bin/sh /jffs/ai-flow/scripts/start.sh start记得给脚本加执行权限chmod x /jffs/scripts/services-start还有一个坑services-start 执行的时候网络可能还没完全就绪。如果程序启动时就要连外网可能会失败。我的做法是在启动脚本里加一个等待循环检测网络通了再启动for i in $(seq 1 30); do if ping -c 1 -W 2 223.5.5.5 /dev/null 21; then break fi sleep 2 done这里用的是公共 DNS 服务器做连通性检测比 ping 网关更可靠因为网关通了不代表外网通。4.4 资源占用实测部署完成后我观察了一周的资源占用情况指标空闲时处理请求时备注CPU 占用 1%5-15%单次请求峰值内存占用12MB25MB含 goroutine 开销磁盘占用15MB15MB二进制 配置网络流量0视请求而定主要是 API 调用这个占用水平对路由器来说完全无感。我同时跑着其他几个服务路由器依然稳定。5. 常见问题与排查实录5.1 程序启动就退出这是最常见的问题通常有几个原因。架构不对编译时 GOARCH 设错了二进制文件跟路由器 CPU 不匹配。用file ai-flow-router看一下文件架构跟uname -m对比。动态链接库缺失忘了加CGO_ENABLED0编译出来的文件依赖系统库。用ldd ai-flow-router检查如果显示 not a dynamic executable 就对了。权限不足二进制文件没有执行权限或者所在分区不支持执行。chmod x加上执行权限确认 JFFS 分区挂载正常。5.2 请求超时或返回 504先看日志确认是哪个步骤超时。如果是引擎调用超时检查网络连通性和 API 服务状态。路由器上的 DNS 解析有时候会出问题可以在配置里直接用 IP 地址或者检查/etc/resolv.conf。如果是流程整体超时可能是步骤太多或者某个步骤特别慢。适当调大流程超时或者优化 prompt 减少 AI 处理时间。5.3 内存占用持续增长这是典型的 goroutine 泄漏或者连接泄漏。检查所有resp.Body是否都关闭了所有context是否都 cancel 了。用pprof可以定位泄漏点但路由器上跑 pprof 比较重我一般是通过日志和代码审查来排查。还有一个可能是日志文件太大。日志要设置轮转或者定期清理。我在启动脚本里加了个简单的清理逻辑保留最近 7 天的日志。5.4 配置修改后不生效检查配置文件的修改时间是否更新了热加载是 30 秒轮询一次改完等半分钟。如果还不生效看日志里有没有 config reload failed 的错误通常是 YAML 语法错误。用在线 YAML 校验工具先验证一下格式。5.5 常见问题速查表现象可能原因排查方法解决方案启动即退出架构不匹配file命令查看重新交叉编译启动即退出缺少动态库ldd命令查看加 CGO_ENABLED0请求 504网络不通ping API 域名检查 DNS 和路由请求 401密钥错误查看日志检查环境变量内存增长连接泄漏代码审查确保 Body 关闭配置不生效语法错误YAML 校验修正格式重启后丢失JFFS 未启用检查挂载启用 JFFS6. 扩展玩法与个人体会6.1 可以继续扩展的方向这套东西跑通之后能玩的花样其实不少。比如加一个定时任务模块每天早上自动抓取几个 RSS 源用 AI 摘要后推送到手机。或者加一个文件监听往某个目录扔一个文本文件自动处理完输出到另一个目录。还可以对接语音助手。路由器上跑个简单的 HTTP 服务语音助手通过局域网调用实现对着音箱说一句话AI 处理完结果播报出来。这个我试过延迟主要在网络请求上本地处理几乎无感。另一个方向是多路由器组网。如果你家里有多个华硕路由器组了 AiMesh可以在主路由上跑编排器子路由上跑轻量客户端形成一个分布式的边缘 AI 网络。不过这个我还没深入折腾理论上可行。6.2 我踩过的几个印象深刻的坑第一个是时区问题。路由器默认可能是 UTC 时间日志时间戳跟本地时间差 8 小时排查问题的时候看得一脸懵。在启动脚本里加export TZAsia/Shanghai解决。第二个是文件描述符限制。路由器默认的 fd 上限比较低并发请求多了会报 too many open files。可以在启动脚本里用ulimit -n 1024调大但要注意别超过系统承受能力。第三个是API 返回内容里的特殊字符。有些 AI 返回的文本里带引号、换行、emoji直接拼到 JSON 里会导致解析失败。一定要用json.Marshal来构造响应不要手动拼字符串。6.3 关于边缘 AI 的一点个人看法把 AI 能力放到边缘设备上最大的价值不是性能而是可用性和隐私。路由器一直在那儿你不需要专门开什么设备就能用。而且数据在本地流转敏感内容不用发到第三方服务当然调用云端 AI 时还是会发出去但至少编排逻辑和中间结果在本地。当然路由器算力有限跑不了大模型。它的定位是编排和网关把请求路由到合适的 AI 服务管理提示词和流程。真正的推理还是在云端。这种边缘编排 云端推理的架构我觉得在家庭和小型办公场景下会越来越常见。最后分享一个小技巧如果你想让服务更稳定可以在启动脚本里加一个看门狗定期检查进程是否存活挂了就自动拉起。我用的是最简单的方案在 crontab 里加一条每分钟检查一次的任务* * * * * /jffs/ai-flow/scripts/start.sh start /dev/null 21因为start.sh里已经判断了是否在运行所以重复执行是安全的。这样即使程序因为某种原因挂了最多一分钟就能自动恢复。
返回列表