ARTICLE DETAIL

资讯详情

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

守护进程军团:基于Unix Socket和JSON-RPC的本地AI微服务架构

守护进程军团:基于Unix Socket和JSON-RPC的本地AI微服务架构 第一次把 Microduck 的音频采集进程从主程序里拆出去单独跑成一个守护进程的时候我盯着终端里滚动的日志长长舒了一口气。之前只要音频库一崩整个小鸭子连话都说不出来拆完之后崩了它能自己重启主逻辑毫发无伤。这篇文章就是围绕 Microduck 的守护进程军团来写的——一组通过 Unix socket 互相通信、用 JSON-RPC 约定接口的微服务架构。如果你手头也有类似的小型 AI 硬件项目或者只是想知道为什么本地几个进程之间还要搞微服务这篇应该能给你一些比框架选型更实在的参考进程怎么划分、socket 怎么管理、协议怎么定、守护进程怎么保活以及那些不跑一遍根本发现不了的坑。1. 为什么 Microduck 最后长成了一支守护进程军团1.1 一个桌面 AI 鸭子到底要干多少活Microduck 不是那种插上电只会嘎嘎叫的玩具。它要做的事情拆开看至少有这么几件持续采集麦克风音频、做唤醒词检测、检测说话停顿VAD、把语音转成文字ASR、把文字丢给大模型推理、把回复文本合成语音TTS、控制喇叭播放另外还要驱动舵机做一些头部动作、点亮眼睛的 LED 之类的交互反馈。这些任务不是顺序执行就完事的。音频采集是 24 小时不间断的唤醒词检测也必须实时TTS 播放的时候动作守护进程还要同步做表情大模型推理可能一次要好几秒。把这么多不同节奏、不同生命周期的东西塞进一个进程里不是不能跑是跑起来之后你会发现自己每天都在救火。1.2 单体架构的崩溃通常不是崩出来的我第一次实现的时候就是单体。程序结构倒也清晰一个主循环几个线程音频回调里塞数据推理线程阻塞等待模型输出。问题是出在那些不干脆的故障上。音频线程和设备断开时如果处理不当回调会一直报错但主线程完全感知不到结果就是小鸭子变成聋子你还以为它在正常待机。TTS 引擎如果加载了一个损坏的声学模型可能在合成到一半的时候段错误整个进程直接退出前面 ASR、LLM 算出来的东西全部白费。最难受的是内存——大模型、语音模型、音频缓冲全在一个地址空间里某个模块的内存泄漏会拖垮所有人。这些问题的本质是不同模块的稳定性要求、资源模型、升级频率都不一样硬塞进一个进程等于让所有人坐同一条船而且这条船还不能靠岸检修。所以我决定把它拆成多个进程每个进程只做一类事进程之间用明确的接口通信。1.3 微服务不一定非得上 HTTP 那套说到微服务架构很多人第一反应是 HTTP REST、注册中心、容器编排那一套。但 Microduck 的场景完全不一样所有进程跑在同一台 Linux 机器上服务之间是本机通信没有跨网络、没有负载均衡、不需要外部访问。这种场景下用 HTTP 反而别扭。每个请求都要走一遍 TCP 协议栈端口还要维护本机防火墙、网络命名空间都可能成为干扰项。gRPC 性能很好但它强依赖 protobuf 代码生成对于一个要经常改接口的玩具项目来说每改一个字段都要重新生成客户端和服务端代码迭代节奏被拖得很慢。最终我选了 Unix domain socketUDS做传输层JSON-RPC 做应用层协议。理由很简单UDS 是本机进程通信里最接近直接内核转发的方案延迟极低还自带文件权限控制JSON-RPC 是文本协议人眼能读、手也能写调试的时候用 socat 手工发一条消息就能测接口不需要任何额外工具链。2. 军团编制进程拓扑与一句对话的完整流转2.1 哪些进程常驻各自负责什么Microduck 目前常驻了六个守护进程它们的角色和对外提供的核心方法如下进程名职责核心 RPC 方法备注microduck-audio麦克风采集、扬声器播放、VAD 切分audio.push_pcm、audio.play_wav、audio.set_volume硬件访问最频繁独立后崩溃可自动重启microduck-asr语音转文字内置唤醒词检测asr.wakeup_register、asr.transcribe加载语音模型内存占用大头之一microduck-llm大模型推理管理对话上下文llm.chat、llm.reset_session流式输出 token耗时最长microduck-tts文本转语音合成 wav 或 pcmtts.synthesize、tts.synthesize_stream支持返回音频缓冲或写到共享目录microduck-motion舵机、LED、按键等 IO 控制motion.play_action、motion.set_led动作序列的调度中心microduck-gateway统一入口、会话状态管理、配置下发gateway.talk、gateway.health、gateway.ping客户端逻辑通常只连它一个每个守护进程都是独立的可执行文件拥有自己的配置节、日志输出和崩溃重启策略。它们之间没有总线和中心那种强耦合gateway 只是入口不是唯一通信路径——比如 audio 检测到唤醒词后可以直接调 asr 接口不必绕一圈经过 gateway。2.2 一次对话的完整链路假设用户对着 Microduck 说了一句今天天气怎么样整个过程可以拆成下面这些步骤microduck-audio 持续把 16kHz 16bit 的 PCM 数据送进 VAD 模块同时做唤醒词检测。这里用的是轻量级唤醒模型每帧打分超过阈值就触发。唤醒触发后audio 通过 UDS 向 gateway 发通知audio.wake_detected同时开始缓存这段语音。gateway 收到通知先把交互状态置为聆听中然后向 asr 发送asr.transcribe请求请求体里带有音频数据的引用路径而不是把几百 KB 的 PCM 直接塞进 JSON——这个设计后面会细说。asr 返回识别文本gateway 把文本拼上系统提示词调用llm.chat。llm 开始流式返回 token每生成一小段就通过通知llm.token推给 gateway。gateway 不是等全部生成完才动而是先把文本缓冲起来直到收到llm.done才认为一轮回答结束。gateway 把完整回复文本发给tts.synthesize拿到合成的音频文件路径再告诉 audio 进程播放。播放开始的同时gateway 调用motion.play_action让鸭子做点头动作眼睛 LED 亮起。全部完成gateway 把状态切回待机向所有进程广播空闲通知。这八步里每一步都是独立的 JSON-RPC 消息任何一步失败对应进程只需要回一个 errorgateway 可以根据错误码决定是重试、降级还是直接告诉用户没听清。2.3 状态同步不靠全局变量拆成多进程之后最大的失落感来自没有全局变量了。以前一个共享结构体就能存的状态现在必须想清楚每个状态放在哪个进程里。Microduck 的原则是状态归拥有者。会话上下文归 gateway 管音频缓冲归 audio 管模型加载状态归各自的模型进程管。其他进程想了解状态只能通过 RPC 查询不许偷偷共享内存。刚开始我觉得这样很麻烦但好处很快显现——任何一个进程崩溃重启后其他进程不需要知道它内部发生了什么只要它重新连上 socket、上报一次gateway.health整个军团就恢复如常。3. Unix socket 上的通信基建不只是快一点3.1 UDS 比 TCP loopback 强在哪本机通信其实用 TCP 的 loopback 接口也能跑但 UDS 有几个实打实的优势。性能上UDS 不走完整的 TCP/IP 协议栈内核在进程之间直接搬运数据单条消息的往返延迟通常在几十微秒量级比 loopback 的 TCP 要低一个档次而且没有端口号、没有三次握手那些开销。Microduck 的高频路径——音频进程往 asr 推数据、gateway 接收流式 token——都是小消息高频率UDS 特别合适。安全上UDS socket 对应一个文件你可以用文件权限控制谁能连。Microduck 的 socket 文件统一放在/run/microduck/目录属组设为microduck权限 0660。这样即使同一个机器上有其他用户也无法随便连接你的语音服务。还有一个隐藏优势是可以在 Unix socket 上传递文件描述符SCM_RIGHTS这个我在后面扩展里会提到。TCP 想做这件事就很难受。3.2 socket 文件的生命周期管理UDS 的 socket 文件有一个很烦人的特性进程正常退出时文件不会自动删除只有进程在退出前显式unlink才行。如果进程被kill -9或者系统崩溃socket 文件会残留在磁盘上。下次启动时如果直接bind会得到Address already in use。Microduck 的解决方式很朴素每个守护进程在启动后、bind 之前先检查目标路径是否存在。如果存在尝试对该路径发起一次连接连上了说明有活跃进程那就直接报错退出连接失败说明是残留文件unlink后再 bind。另外路径长度要小心。Unix socket 地址有长度限制大约是 108 字节。如果你把 socket 放在一个很深的目录里路径一长就 bind 失败。这也是我坚持用/run/microduck/这种短路径的原因别把 socket 放在用户目录下一长串路径里。3.3 消息分帧为什么不能直接 read()很多人第一次写 socket 通信时会犯一个错误read()一次假设读到的就是一个完整的 JSON 消息。实际上 TCP 流和 UDS 流都是字节流不保证消息边界。你发两条消息对端可能一次read就拿到了两条拼在一起的数据也可能一条消息比较大要分两三次read才读完。这就是沾包和半包问题。Microduck 的分帧策略是每条 JSON-RPC 消息以换行符结尾接收方维护一个按行切割的缓冲遇到完整的一行才解析。这个方法简单可靠唯一要注意的就是 JSON 字符串内部如果含换行符需要做转义标准 JSON 序列化会自动处理成\n不会产生裸换行所以用现成的 JSON 库序列化就不会出问题。对于更大的音频数据或者流式 token 的推送换行分帧依然适用只是要保证单个 JSON 消息不要太大——Microduck 把音频文件路径作为引用传给 asr而不是把 PCM 直接塞进 JSON就是避免一条消息几十 MB。3.4 用 asyncio 实现一个轻量 RPC 服务端Microduck 的守护进程大部分是 Python 写的通信层直接用了 asyncio 自带的unix_server。以 asr 进程为例核心骨架大概是这样import asyncio import json import os import socket SOCK_PATH /run/microduck/asr.sock class JsonRpcServer: def __init__(self): self.handlers {} def register(self, method, fn): self.handlers[method] fn async def handle_line(self, line, writer): try: req json.loads(line) except json.JSONDecodeError as e: resp {jsonrpc: 2.0, error: {code: -32700, message: parse error}, id: None} writer.write((json.dumps(resp) \n).encode()) return method req.get(method) req_id req.get(id) if not req_id and req.get(id) ! 0: # 通知类消息不需要回包 handler self.handlers.get(method) if handler: asyncio.create_task(handler(req.get(params, {}))) return handler self.handlers.get(method) if not handler: resp {jsonrpc: 2.0, error: {code: -32601, message: method not found}, id: req_id} else: try: result await handler(req.get(params, {})) resp {jsonrpc: 2.0, result: result, id: req_id} except Exception as e: resp {jsonrpc: 2.0, error: {code: -32603, message: str(e)}, id: req_id} writer.write((json.dumps(resp) \n).encode()) await writer.drain()这段代码没有处理连接断开时的缓冲残留但已经能看出核心逻辑按行读取、解析 JSON、分发到注册的 handler、统一写回 JSON-RPC 格式的响应。每个守护进程都复用这套骨架只是注册的 method 不同。3.5 心跳、超时与断线重连进程之间的连接不可能永远稳定。某个守护进程可能因为模型推理超时被卡住可能系统休眠后 socket 状态异常。Microduck 的做法是双向心跳gateway 每 5 秒向所有常驻进程发一个gateway.ping任一进程超过 10 秒没有响应gateway 就认为它挂了交给 systemd 的重启逻辑处理。反过来每个守护进程也会监听 gateway 是否还在。如果 gateway 长时间不响应子进程会主动关闭 socket进入重连循环。重连策略用指数退避第一次 0.5 秒然后 1 秒、2 秒最多 10 秒一次直到重新连上。4. JSON-RPC 协议设计让每个接口都经得起手测4.1 为什么选定 JSON-RPC 2.0 而不是自造协议自己定义一套二进制协议固然灵活但调试成本太高。JSON-RPC 2.0 是一个非常轻量的规范核心就四个要素jsonrpc版本字段、method方法名、params参数、id请求标识。它有明确的错误码定义支持通知notification不需要回包和批量请求足够覆盖 Microduck 的所有场景。选它最重要的理由是可手测。我用 socat 连上 asr 的 socket手打一行 JSON 就能验证接口不需要写任何客户端代码socat - UNIX-CONNECT:/run/microduck/asr.sock {jsonrpc: 2.0, method: asr.transcribe, params: {path: /tmp/tts_test.wav}, id: 1}然后就能看到返回结果。这个体验是 gRPC 很难给的。4.2 方法命名与参数约定Microduck 的方法名统一用模块名.动作的格式比如audio.play_wav、motion.play_action。参数一律是对象JSON object不用数组位置参数——位置参数虽然省字节但调用方很容易搞错顺序而且在演进时新增参数要破坏兼容性。所有方法都接受一个可选的trace_id字段用于跨进程追踪一次请求完整链路。这不是 JSON-RPC 规范强制要求的东西但对排查问题极其有用。每次 gateway 发起一个对话就生成一个 UUID 放进参数里所有相关进程的日志都打上这个 trace_id。后面你想看这一句话到底在哪个进程花了多少毫秒一条 grep 就出来了。4.3 流式输出在 JSON-RPC 里的正确姿势大模型推理是流式的TTS 合成也可以是流式的。但 JSON-RPC 的 request/response 是一问一答的同步模型一个llm.chat请求如果等到全部 token 生成完才返回用户体验就是对着鸭子发呆好几秒。Microduck 解决方式是订阅 通知。客户端先调用llm.chat_stream传一个stream_id服务端立刻返回 200 表示已受理然后服务端通过通知消息持续推送结果{jsonrpc: 2.0, method: llm.token, params: {stream_id: s-001, delta: 今天}} {jsonrpc: 2.0, method: llm.token, params: {stream_id: s-001, delta: 天气}} {jsonrpc: 2.0, method: llm.done, params: {stream_id: s-001, reason: stop}}通知消息没有id服务端不会回包客户端也不用等待。第一次看这段代码的人可能会问那异常怎么办答案是服务端在任何异常时都会发一条llm.error通知里面带错误码和 message客户端在一个事件循环里统一处理。4.4 错误码设计别把所有失败都叫 Internal errorJSON-RPC 2.0 定义了五个保留错误码其中-32700解析错误、-32601方法不存在、-32602参数不合法、-32603内部错误最常用。但只有这些码是不够的一个语音助理的失败原因五花八门。Microduck 扩展了错误体系应用层错误从 1000 开始错误码含义典型场景1000模型未加载刚启动模型还在加载就收到了推理请求1001音频设备忙正在播放时又收到播放请求1002推理超时LLM 生成超过 30 秒1003输入校验失败音频格式不是预期的采样率1004资源不足共享内存分配失败错误响应里还带一个data字段里面是进程名、trace_id、具体错误详情方便日志系统自动聚合。4.5 版本协商与向后兼容进程之间不可能保证永远同时升级。Microduck 的办法是在握手阶段协商版本每个服务端在启动后支持一个system.info方法返回自己的版本号和支持的方法列表。gateway 启动时逐个查询如果发现某个进程版本过旧就在日志里打警告并决定是否降级使用旧接口。这个设计让逐个重启进程做升级成为可能。我要升级 llm 进程时不需要停掉 audio 和 tts只要把新二进制放上去systemd 重启这个单元即可其他进程毫不知情。5. 守护进程管理与保活把崩了能自己爬起来做成默认能力5.1 systemd 是比手写 daemonize 更好的选择Linux 下做守护进程传统做法是fork()两次、setsid()、重定向标准输入输出非常繁琐且容易出错。Microduck 直接用了 systemd 的用户实例user service每个进程一个 unit 文件systemd 负责启动、守护、崩溃重启和日志收集。以 asr 进程为例~/.config/systemd/user/microduck-asr.service大致长这样[Unit] DescriptionMicroduck ASR Daemon Aftermicroduck-audio.service [Service] ExecStart/opt/microduck/bin/microduck-asr --config /etc/microduck/config.toml Restarton-failure RestartSec2 LimitNOFILE65536 MemoryMax512M CPUQuota50% [Install] WantedBydefault.targetRestarton-failure保证非正常退出会重启RestartSec2避免崩溃后疯狂重启把系统拖垮。MemoryMax和CPUQuota是单独给进程上保险防止某个模型进程泄漏内存拖死整机。5.2 连接失败的快速自愈光有进程重启还不够。进程重启之后要能重新连上其他进程的 socket才算是真正的自愈。Microduck 的做法是每个进程启动后进入一个就绪等待循环尝试连接/run/microduck/下的所有兄弟 socket连接成功的标记为可用并且把gateway.ping作为就绪探针——只要 gateway 能 ping 通就认为军团恢复。还有一个细节重启后的进程要重新上报一次自己的状态因为 gateway 可能在它挂掉的那段时间里已经切换到了降级模式。Microduck 给每个进程加了一个gateway.register方法进程启动后就调用它把自身能力列表上报给 gatewaygateway 合并这些信息更新全局能力表。5.3 日志结构化是排查多进程问题的唯一出路单体时代看日志很简单就是一个文件从头翻到尾。拆成六个进程后如果每个进程各写各的文件排查一个问题要在六个文件之间来回跳非常痛苦。Microduck 的做法是统一走 journald每个进程的日志都按 JSON 结构化输出至少包含时间、进程名、level、trace_id、事件名。查问题时只需要一条 journalctl 命令journalctl --user -u microduck-asr -f -o json | grep trace_id:xxx就能把一次请求在某个进程里产生的所有日志全部捞出来。5.4 资源约束别等爆了才想起来守护进程常驻意味着它们会一直占用资源。Microduck 踩过一个坑asr 进程加载模型后 RSS 占用了 1.2GB但 systemd 没有限制结果 tts 进程启动时系统内存不够OOM Killer 直接随机杀了一个进程杀中的正好是 gateway整个鸭子当场断线。从那之后我学乖了每个单位文件里都写死了资源上限。模型进程给大点音频和动作进程给小点宁可让某个进程启动失败也不要让 OOM Killer 替我决策。6. 跑通整个军团从编译到第一句语音回复6.1 环境准备Microduck 的依赖不算多但版本要卡准。我用的是 Ubuntu 22.04 LTSPython 3.11编译工具链用 gcc 12。核心依赖包括libasound2-devALSA 音频接口portaudio19-dev跨平台音频采集cmake动作守护进程的 C 扩展编译python3-venvPython 依赖隔离按下面步骤准备环境sudo apt update sudo apt install -y build-essential cmake libasound2-dev portaudio19-dev python3-venv git clone https://github.com/microduck/microduck.git cd microduck python3 -m venv .venv source .venv/bin/activate pip install -r requirements.txt ./build.shbuild.sh 会编译 C 扩展、下载模型清单、生成默认配置。整个过程大约十分钟大部分时间花在下载模型上。6.2 最小配置清单跑通之前先看一眼配置。config.toml里的关键项如下[system] run_dir /run/microduck state_dir /var/lib/microduck [models] llm_path /opt/microduck/models/llm/qwen2.5-0.5b-q4.gguf asr_model /opt/microduck/models/asr/whisper-tiny.pt tts_voice /opt/microduck/models/tts/voice_duck.onnx [audio] sample_rate 16000 device defaultrun_dir就是所有 socket 文件所在的目录确保它是 0770 权限且属主是运行用户。模型路径要确认存在asr 和 llm 进程启动时加载模型失败会直接退出而且不会告诉你模型路径不对以外更多信息。6.3 模型准备与训练从零到能对话很多人在这一步卡住。Microduck 的模型分三块ASR、LLM、TTS。ASR 直接用了开源的 Whisper 微调方案LLM 则用 LoRA 在特定对话风格数据上微调TTS 需要准备一小段目标音色的录音。训练流程大概分四步收集数据。对于对话型 LLM至少要准备几百条用户 - 鸭子回应的多轮对话。语音数据则需要采集目标场景下的音频并转写。数据清洗和格式化。LLM 微调数据要按对话角色区分建议用 JSONL 格式每一行是一个样例。语音数据重点处理背景噪声和静音段切成可控长度的片段。微调与导出。LLM 用 LoRA 微调后合并权重再导出为 GGUF 格式并做 Q4 量化这样在普通边缘设备上也能跑。ASR 微调后输出为 PyTorch 权重或 ONNX。验证。把模型放到models目录重启对应守护进程用microduck-ctl手工发几条消息测试。最容易被忽略的是训练数据里的角色一致性。Microduck 作为一个桌面宠物它的回答语气必须统一如果训练数据里一会儿像客服一会儿像百科全书微调出来的效果就会非常奇怪。数据质量比数据量重要得多三百条高质量对话的效果好过三千条从网上乱抓的语料。6.4 验证连通性一条命令看整个军团跑起来之后先别急着对鸭子说话先验证进程都在systemctl --user status microduck-*再看 socket 文件是否生成ls -l /run/microduck/最后用 socat 手工发一个 pingecho {jsonrpc:2.0,method:gateway.ping,params:{},id:1} | socat - UNIX-CONNECT:/run/microduck/gateway.sock如果一切正常你会看到类似{jsonrpc: 2.0, result: {status: ok, workers: 6}, id: 1}的返回。到这一步整个军团的通信链路就通了。7. 实测下来最值得说的三个坑7.1 socket 文件权限导致的幽灵拒绝第一次部署到新机器上所有进程都起来了但 gateway 始终连不上 asr 的 socket。排查了很久才发现是/run/microduck/目录的权限问题——新系统上/run是 root 所有我手动创建的目录属主是 root而 asr 进程是用普通用户跑的根本没法 bind。解决方式是给目录设置正确的属主和属组再配合 systemd 的RuntimeDirectorymicroduck指令让 systemd 在启动服务时自动创建/run/microduck并设置好权限这样就不用每次手动清理和授权。7.2 流式推送时的半包问题llm 进程在推送 token 时客户端偶尔会读到半个 JSON。最初我以为是协议分帧有问题后来发现是作为服务端的 llm 进程在推送时使用了非原子性的 write——一次推送内容过长被内核拆成了两次发送而客户端的读循环没有正确处理只读到了半行的情况。修法不是把消息改短而是让读取侧严格按缓冲 换行分割来处理。任何基于 socket 做 JSON 分帧的程序这个逻辑都是必须的不要假设一次 read 就一定得到完整的一行。7.3 音频守护进程的高 CPU 占用音频进程在空闲时依然有 40% 的 CPU 占用。原因是音频回调里做了太多不必要的处理包括把每一帧都复制到多个地方、实时计算 VAD 但没有做降采样、还开着调试日志打印每个音频块的起始时间。优化后把音频回调只保留拷贝 入队两个动作VAD 在单独的消费线程里批量算空闲 CPU 从 40% 降到 3%。这个教训适用于所有实时音频程序回调里做的事越少系统越稳定。7.4 当前性能参考用gateway.talk做一轮完整问答实测延迟分布大致如下阶段耗时唤醒检测200-400msASR 识别300-800msLLM 首 token 时间800-2500msTTS 合成完整回复500-1500ms进程间通信总开销 2ms可以看到UDS JSON-RPC 在整个链路里几乎可以忽略不计真正的瓶颈永远在模型推理上。这验证了当时的架构选型——通信层的性能根本不成问题关键是别让通信层的复杂度反过来拖垮开发效率。后面我打算继续做两件事一是用SCM_RIGHTS把音频文件的文件描述符直接传给 asr 进程省掉中间落盘的 IO二是给动作守护进程加一套简单的动作脚本 DSL让非程序员也能给鸭子编排新表情。架构这个东西拆到某个程度就不再是技术问题而是怎么让每个进程都保持够用的边界。Microduck 目前的样子还算是我满意的状态。
返回列表