
Microduck这个项目我折腾了不短时间从最初直接把训练进程跑在终端前台到后来被“终端一关全完蛋”“跑到一半断连”“显存爆掉连渣都不剩”这种事反复教育才慢慢意识到想把Microduck稳定地用起来单靠一条命令是不够的得给它配一支能长期驻留、各管一摊、互相协作的守护进程军团进程之间用Unix socket走JSON-RPC通信。这篇就是我把这套架构从设计到落地、再到踩坑修复的完整记录。如果你正打算在自己机器上跑Microduck做微调训练或者已经能跑通但觉得“跑起来容易跑稳很难”这篇文章应该能帮你省掉不少弯路。我会把进程怎么拆、socket怎么规划、协议怎么定、训练任务怎么调度、出问题怎么排查这些事讲透里面所有步骤和代码都是可以直接抄走用的。1. 为什么Microduck要配一个“守护进程军团”1.1 Microduck跑通之后真正难的是什么Microduck本身是一个面向本地训练和推理场景的轻量级LLM工具链主打的是把模型微调、LoRA训练、推理这些都压在个人开发机或小型GPU服务器上完成。网上聊“microduck怎么训练”的帖子不少大多止步于“能跑”也就是命令行敲下去、日志刷起来、loss往下降就算跑通了。但真到把训练当日常任务用的时候问题立刻浮出水面。最典型的一个场景你拿一台没有显示器的服务器训练SSH连上去启动训练然后人走了网络一抖动终端断开训练进程收到SIGHUP直接退出跑了12小时的checkpoint全废。这就是常说的“前台进程扛不住断连”。再一个训练和推理放在同一个进程里训练吃掉全部显存推理请求来了只能干等跟死机一样。还有如果你想把Microduck集成进自己的工具链比如写个Web界面、写个定时任务、接个团队共用的API总不能每次都去服务器上手动敲命令。“跑通”只解决了“能不能训”的问题真正难的是“能不能稳定地训、随时地推理、方便地被调用”。这时候就需要把Microduck拆成若干后台服务也就是守护进程来承载。1.2 从单进程到多守护进程到底解决了什么问题我最初也怀疑过一个本地工具搞这么重的架构是不是过度设计。但把需求列清楚再想就会发现拆分不是“为了架构而架构”而是每一条都有真实的痛点支撑训练任务要跨会话存活必须有一个脱离终端的守护进程来托管。训练时占用GPU资源推理服务不能跟着训练一起阻塞需要独立进程隔离。数据预处理、格式转换这些脏活累活不该混进训练主流程。训练进度、日志、状态查询需要对外提供统一入口不能靠翻终端窗口。崩溃要能自愈至少主控进程要能把挂掉的子进程捞回来。“守护进程军团”这个词看起来有点中二但它描述的事实是不是单个daemon而是一组各司其职的daemon进程由一个总控负责编排。它们共享同一套通信机制但这个机制不是主从服从式的“命令下发”而是服务之间互相调用谁需要谁的数据就去请求谁。这样拆完之后训练中断、并发推理、异步查询、集成API这些问题都能在进程边界上解决而不是在代码里到处堆特例。1.3 通信选型HTTP、gRPC、共享内存都不如Unix socket上的JSON-RPC进程拆开了进程之间怎么说话就成了关键问题。可供选择的方案不少我逐一评估过方案优势劣势我的评估HTTP over TCPREST通用、调试方便端口暴露在网络栈上有安全风险HTTP解析开销相对大本地进程间通信没必要走TCPgRPC性能好、强类型、流式传输引入protobuf编译链项目复杂度大Microduck这种轻量工具链背着protobuf太重共享内存延迟极低、吞吐极大同步、生命周期和权限管理都靠手写很容易出隐蔽bug除非要传超大张量否则收益不成正比Unix socket 自定义二进制协议性能好、可控性高协议解析要自己设计没有现成框架适合对性能苛刻的场景但开发量大Unix socket JSON-RPCUnix socket隔离性好、性能够用JSON-RPC协议简单、跨语言友好JSON编码有少量开销本地守护进程通信场景下的最佳平衡点我这里不是做性能对比测试而是讲取舍逻辑。本地进程间通信首先关注的是“不能被外部随便访问”Unix socket通过文件系统权限就能锁住天然比TCP端口安全。其次要关注开发效率和调试成本JSON-RPC 2.0是一个足够简单也足够规范的协议Python、C、Node、Go都有现成实现哪怕手写解析也不难。最后才是性能训练任务每步迭代要几秒钟推理任务一次也就几十到几百毫秒JSON编解码这点开销完全可以忽略真正的大数据数据集文件、模型文件根本不该走RPC走文件系统就对了。所以最终定下来进程通信用Unix domain socket承载协议用JSON-RPC 2.0风格并在传输层做了一点扩展专门处理训练任务这种“长时间运行”的方法调用。2. 架构设计与进程职责先画好军团编成表2.1 五个守护进程各自的职责整个守护进程军团由五个角色组成名字带着Microduck的“duck”印记duckctl总控调度进程负责启动、监控、拉起其他守护进程也是外部请求的统一入口。ducktrain训练服务进程专门承担LoRA微调、全参微调这类训练任务持有GPU资源。duckinfer推理服务进程负责模型加载和推理请求跟训练进程隔离开避免互相挤占显存。duckdata数据处理服务进程负责数据集加载、格式校验、tokenize、样本切分等前置工作。duckmon监控与日志服务进程收集其他进程的指标、日志和心跳供查询和告警。这五个角色不是平级的duckctl是“军团长”其他四个是“兵种”。duckctl不直接干活它管调度、管心跳、管崩溃恢复。你外部想发起训练请求打到duckctlduckctl再转发给ducktrain你想查训练进度也是打到duckctl由它去ducktrain那边取状态再返回。为什么要加一层duckctl而不是直接调ducktrain因为如果外部请求直接打给训练进程你就没法在中间做鉴权、限流、任务编排和状态聚合。多这一层外部使用方永远只面对一套API内部谁挂了、谁来接班对外是不可见的。2.2 socket路径、目录结构与权限规划Unix socket是通过文件系统路径来寻址的所以路径规划就是个不能拍脑袋的事情。我采用的是每个进程一个独立socket文件统一放在/run/microduck/目录下/run/microduck/ ├── duckctl.sock ├── ducktrain.sock ├── duckinfer.sock ├── duckdata.sock └── duckmon.sock选/run而不是/tmp是因为/run是系统开机后创建的运行时目录更适合放守护进程的socket和pidfile/tmp是任何人都能写的公共目录容易出权限问题和命名冲突。如果你用户名权限不够创建/run/microduck可以退一步用/tmp/microduck-$USER/但目录权限必须设成700保证只有你这个用户能访问。socket文件权限我建议显式设置成0660并用chown把属主设为服务账号。为什么不是0666因为0666等于允许机器上任何用户都能往你的训练进程发请求谁都能让你加载模型、删任务这不是一个负责任的服务该有的态度。Unix socket最舒服的一点就是你不用像TCP那样去搞TLS文件权限本身就是安全层。pidfile也放在这个目录下格式是服务名.pid比如duckctl.pid。这个文件是给duckctl检测其他进程是否存活用的也是手工排查时最快的判断依据。2.3 JSON-RPC 协议设计字段、错误码与长任务约定协议底座是JSON-RPC 2.0但为了适配训练这种长任务场景我做了几处裁剪和扩展。先看一个标准的请求长什么样{ jsonrpc: 2.0, id: 202401071530, method: train.create, params: { model_path: /models/llama-duck-7b, lora_out: /output/duck-lora, dataset_path: /data/duck_train.jsonl, epochs: 3, lr: 2e-5 } }响应对应{ jsonrpc: 2.0, id: 202401071530, result: { task_id: train-7f3a9c, status: accepted } }这是标准的请求-响应模式适合创建任务、查询状态这类操作。但对于训练任务真正发起训练之后训练可能要跑几十分钟甚至几小时客户端不可能一直拿着socket连接干等。所以我把训练类方法分成两个阶段提交阶段和查询阶段。提交阶段只返回“任务是否被受理”以及task_id查询阶段用新方法轮询任务状态。这是JSON-RPC本身不支持“异步长任务”这个语义的务实补偿。协议解析不难难的是什么样的调用模型能真正被训练流程用起来。很多人在这一步翻车就是因为拿着同步RPC的思路去调训练任务一个请求过去客户端挂起几小时中间断一下全废。扩展约定还包括固定字段。每个请求必须带method前缀区分服务域例如train.create走ducktrain、infer.generate走duckinfer、data.validate走duckdata。这样在设计方法注册表的时候一眼就能看出方法的归属。错误码也做了统一约定我用负数表示协议层错误正数表示业务层错误。标准JSON-RPC已经定义了-32700解析错误、-32600无效请求、-32601方法不存在、-32603内部错误这些保留。业务错误从1000开始递增例如1001表示数据集不存在1002表示GPU显存不足1003表示任务已存在。2.4 守护进程生命周期管理daemonize、pidfile、日志与退出守护进程和普通前台进程最大的区别在于生命周期。前台进程跟着终端走终端关它就死守护进程必须脱离终端、脱离会话由系统init接管这样才能做到“关终端不关进程”。传统daemonize流程大致是fork一次让父进程退出子进程调用setsid脱离会话再fork一次防止重新获取控制终端然后切换工作目录、设置umask、重定向标准输入输出到日志文件。这套流程在C/C里要自己写Python里可以用python-daemon库双fork、setsid、umask这些细节库都封装好了。但我不建议所有人上来就自己实现daemonize。现代Linux系统上更省心的做法是直接用systemd管理这些守护进程或者在开发阶段用nohup加顶住。我自己的实操是开发调试用前台模式加环境变量控制正式跑的时候用systemd的Typesimple托管每个服务一个unit文件谁挂了systemd帮我拉起来。守护进程还有一个关键动作写pidfile。pidfile帮我们回答三个问题进程是否在跑、进程号是多少、上次残留的进程是不是僵尸。每次启动时先读pidfile如果发现进程还活着就直接拒绝重复启动如果pidfile存在但进程不存在说明上次没有正常退出需要清掉残留文件再启动。日志方面我的原则是“每个进程一个日志文件统一由duckmon收集摘要”。比如ducktrain.log里既有Microduck训练本身打的进度也有RPC服务层的访问日志。这样排查的时候先把RPC访问日志翻一遍确认请求到没到底层再决定是不是要往训练日志里深挖。3. 核心实现细节socket层与JSON-RPC路由3.1 Unix socket 服务端骨架Python 示例Microduck本身是C写的主力但守护进程军团的服务端骨架我用了Python主要原因是开发速度快、热更新方便而且RPC这层对性能要求不高瓶颈在训练和推理那一侧。这里分享一个最简的Unix socket服务端骨架import json import os import socket import logging SOCKET_PATH /run/microduck/ducktrain.sock logging.basicConfig( filename/var/log/microduck/ducktrain.log, levellogging.INFO, format%(asctime)s %(levelname)s %(message)s, ) def handle_line(line: str) - dict: 单条JSON-RPC消息处理入口。 try: req json.loads(line) except json.JSONDecodeError: return {jsonrpc: 2.0, id: None, error: {code: -32700, message: Parse error}} req_id req.get(id) method req.get(method) params req.get(params, {}) handler ROUTER.get(method) if handler is None: return {jsonrpc: 2.0, id: req_id, error: {code: -32601, message: fMethod not found: {method}}} try: result handler(params) return {jsonrpc: 2.0, id: req_id, result: result} except Exception as exc: return {jsonrpc: 2.0, id: req_id, error: {code: -32603, message: str(exc)}} def serve_forever(): os.makedirs(os.path.dirname(SOCKET_PATH), exist_okTrue) try: os.unlink(SOCKET_PATH) except FileNotFoundError: pass sock socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) sock.bind(SOCKET_PATH) os.chmod(SOCKET_PATH, 0o660) # backlog设置成5还是128取决于并发量本地服务10足够 sock.listen(10) logging.info(ducktrain socket listening on %s, SOCKET_PATH) while True: conn, _ sock.accept() with conn: buffer b while True: chunk conn.recv(65536) if not chunk: break buffer chunk # 按换行符切分一条JSON-RPC消息一行 while b\n in buffer: line, buffer buffer.split(b\n, 1) if not line.strip(): continue resp handle_line(line.decode(utf-8, errorsreplace)) conn.sendall((json.dumps(resp) \n).encode(utf-8)) if __name__ __main__: serve_forever()这段代码有几个细节值得说。一是按换行符切分消息避免TCP粘包问题。Unix socket虽然是流式接口但本地通信量不大用换行分隔是JSON-RPC over stream场景里最简单粗暴且有效的方案。二是每次accept后先读完整行再回包天然实现了“一问一答”的同步语义逻辑清楚。三是启动时先尝试unlink旧的socket文件否则上次进程残留的socket文件会导致bind失败报“Address already in use”。3.2 RPC 方法注册与路由实现ROUTER这个字典就是方法注册表把方法名字符串映射到实际处理函数ROUTER { train.create: train_create, train.status: train_status, train.cancel: train_cancel, infer.load: infer_load, infer.generate: infer_generate, }每个handler接收params字典返回可JSON序列化的结果。我建议所有handler都套一层统一的异常捕获就像上面代码里的try-except那样把异常转成JSON-RPC标准错误返回给调用方而不是让连接断开。连接一断客户端那边只会看到笼统的“connection lost”问题定位的成本立刻翻倍。路由层还有一个容易被忽略的点方法的幂等性。训练创建任务这种操作客户端超时后重试很可能造成同一个任务被提交两次。我的处理是在train.create里以(dataset_path, model_path, lora_out, epochs, lr)算一个签名如果相同签名的任务已经在运行或排队就直接返回现有task_id而不是新建任务。这个设计让我在排查“重复训练”问题时省了大力气。3.3 训练长任务轮询与通知的节奏训练任务被创建之后ducktrain进程内部会起一个线程去跑Microduck训练RPC服务端线程不能阻塞。我的任务模型是train.create把训练请求塞进一个队列立即返回task_id后台线程从队列里取任务调用Microduck的训练接口不断更新任务状态到内存字典和磁盘状态文件。客户端查状态就用train.status方法{jsonrpc: 2.0, id: 2, method: train.status, params: {task_id: train-7f3a9c}}返回{jsonrpc: 2.0, id: 2, result: {status: running, current_epoch: 2, total_epochs: 3, loss: 1.342, progress: 0.67}}轮询间隔我推荐3到5秒一次。太频繁会给ducktrain增加没意义的RPC压力太稀疏则会让客户端界面看起来反应迟钝。如果你需要事件驱动的推送也可以在JSON-RPC里定义一个notify方法服务端主动向duckmon推送任务状态变化。但我自己实测下来在本地服务场景下轮询比推送简单可靠得多推送还要处理客户端离线、重连、补发等等问题增益很有限。所以我的结论是先轮询等确实出现秒级延迟感知需求再上推送。3.4 容易被忽略的参数backlog、超时、心跳与权限验证这几个参数看着不起眼但每一个都对应一次真实的线上故障。backlog是listen接口的第二个参数表示内核维护的连接队列长度。本地进程通信并发量通常不大但也不是没有极端情况比如你写了个监控脚本每秒钟来探活加上正常的RPC请求瞬间就有几十个连接排队。backlog设成10还是128取决于你对自己服务的认知。我给所有服务都统一设了64既不吃太多内核内存也能扛住探活和正常请求的短时高峰。超时分两层。第一层是socket自身的收发超时服务器端每个连接都建议设置一个读超时比如30秒内没收到完整请求就断开防止客户端半开连接把服务端资源挂住。第二层是任务查询的“快速失败”语义train.status这类查询方法必须在几百毫秒内返回如果查询本身都超时说明被调方已经卡死客户端应该立刻报错而不是无限重试。心跳是我在duckmon里加的一个机制。每个守护进程每隔10秒往duckmon上报一次心跳包含进程号、内存占用、显存占用、当前任务数。如果连续3个心跳周期没收到某个进程的心跳duckmon就通知duckctl由duckctl决定是重启还是告警。这套机制让我在训练过程中能及时发现“进程还活着但卡死在IO”这种假死状态。权限验证方面Unix socket除了文件权限之外还可以通过SO_PEERCRED拿到对端进程的PID和UID。这个能力很关键即使socket文件权限设得再严也存在进程间模拟访问的可能SO_PEERCRED可以确认对端是不是被信任的进程。我在duckctl的访问层做了校验只有UID为服务账号且PID存在于已注册进程列表的请求才会被受理其余的直接拒绝。这样即使有人拿到了socket路径也没法轻易发假请求。4. 实操记录把Microduck套进守护进程军团4.1 环境准备与编译先让Microduck能跑在搭守护进程军团之前Microduck本身得先在目标机器上跑通。我用的机器是一张RTX 4090 24G系统是Ubuntu 22.04依赖主要是CMake、CUDA Toolkit和gcc。Microduck的编译流程跟很多C项目一样大概是这样git clone https://github.com/microduck/microduck.git cd microduck mkdir build cd build cmake .. -DGGML_CUDAON make -j$(nproc)-DGGML_CUDAON是让ggml后端启用CUDA加速如果机器是Apple Silicon可以用-DGGML_METALON没有GPU纯CPU跑就什么都不加。编译完主要产物是microduck-train和microduck-infer两个可执行文件前者负责训练后者负责推理。这里有一个我在第一次编译时踩过的坑CMake缓存。如果你先以CPU模式编译过一次再切到CUDA模式必须把build目录整个删掉重新配置否则就算加了-DGGML_CUDAON也可能继续用旧的CPU配置。这是一个很小但能卡住新人半小时的问题。编译好之后先手动拿一个mini数据集跑一次训练确认底层训练链路是通的再做后面的封装。这一步千万别跳守护进程军团只是一个外壳外壳再稳底层训练器本身跑不通也是白搭。4.2 启动军团的标准流程我写了一个duckctl start命令严格按依赖顺序启动各个守护进程。启动顺序有讲究先duckdata因为训练和推理都可能依赖数据服务再duckmon让监控从最开始就能收心跳接着ducktrain和duckinfer最后启动duckctl自己。中间任何一个进程起不来启动流程直接中止不向下走。# 创建运行时目录 sudo mkdir -p /run/microduck sudo chown $USER:$USER /run/microduck chmod 700 /run/microduck # 按顺序启动 python3 services/duckdata.py --daemon python3 services/duckmon.py --daemon python3 services/ducktrain.py --daemon python3 services/duckinfer.py --daemon python3 services/duckctl.py --daemon启动之后的第一件事不是急着发训练请求而是检查进程和socket文件是否都就位ls -l /run/microduck/ # 应该看到5个.sock文件和对应的.pid文件 ps aux | grep duck # 应该看到5个python3进程如果socket文件存在但没有任何进程监听多半是上次程序非正常退出留下的残骸。解决办法就是删掉对应的sock文件再重启这一步我在服务启动脚本里已经做了自动清理手工排查时也经常要手动删一次。4.3 用 JSON-RPC 客户端打通训练与推理闭环启动就绪后我用一个Python客户端做完整链路测试。先创建训练任务import socket, json def rpc_call(sock_path, method, params, req_id): line json.dumps({jsonrpc: 2.0, id: req_id, method: method, params: params}) \n s socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) s.connect(sock_path) s.sendall(line.encode()) resp b while not resp.endswith(b\n): resp s.recv(65536) s.close() return json.loads(resp) # 提交训练任务到 duckctl result rpc_call( /run/microduck/duckctl.sock, train.create, { model_path: /models/llama-duck-7b, lora_out: /output/duck-lora, dataset_path: /data/duck_train.jsonl, epochs: 3, lr: 2e-5 }, req_id1001 ) print(result) task_id result[result][task_id]这一步如果返回accepted说明任务已经进入ducktrain的任务队列。接着轮询状态import time for _ in range(120): status rpc_call( /run/microduck/duckctl.sock, train.status, {task_id: task_id}, req_id1002 ) st status[result][status] print(fstatus: {st}, loss: {status[result].get(loss)}) if st in (finished, failed, canceled): break time.sleep(5)训练完成后把生成的LoRA权重加载到推理服务里然后生成文本rpc_call( /run/microduck/duckctl.sock, infer.load, {lora_path: /output/duck-lora, model_path: /models/llama-duck-7b}, req_id1003 ) resp rpc_call( /run/microduck/duckctl.sock, infer.generate, {prompt: 写一段关于Unix socket的介绍, max_tokens: 256}, req_id1004 ) print(resp[result][text])这套测试跑通基本就证明军团整个链条是通的。后续无论是接Web界面、接定时任务还是接团队API都会轻松很多。4.4 训练数据与模型输出的约定训练环节能不能跑顺很大程度取决于数据和输出路径的约定。Microduck常用的数据集格式是JSON Lines每行一条样本训练字段一般包含instruction、input和output指令微调场景下尤其如此{instruction: 解释一下什么是Unix domain socket, input: , output: Unix domain socket是一种用于同一台机器上进程间通信的socket机制通过文件系统路径作为地址。}我把数据集统一放在/data/microduck/下面命名规则是任务名_train.jsonl和任务名_val.jsonl。duckdata服务启动时会扫描这个目录生成一个数据清单训练请求里只需要告诉它“数据集名”不需要传完整路径。这样做的目的是把路径管理收口到一处避免训练请求里谁都能传一个任意路径既难管理又有安全风险。模型输出方面的约定是LoRA权重统一输出到/output/microduck/任务名/目录目录里至少包含adapter_model.bin、adapter_config.json和一份training_stats.json训练摘要。训练摘要里有最终loss、每轮loss、训练耗时、学习率、样本数等这些信息会被ducktrain在任务结束回填到train.status的返回结果里方便上层做报告和分析。4.5 一次完整的 LoRA 微调任务参数下面是我在16GB显存机器上跑一个7B模型LoRA微调的常用参数组合直接通过JSON-RPC提交{ jsonrpc: 2.0, id: 1001, method: train.create, params: { base_model: llama-duck-7b, dataset: alpaca_duck_train, val_dataset: alpaca_duck_val, lora_out: /output/microduck/alpaca_duck_run1, lora_r: 16, lora_alpha: 32, lora_dropout: 0.05, target_modules: [q_proj, v_proj, k_proj, o_proj], epochs: 3, batch_size: 4, gradient_accumulation_steps: 4, learning_rate: 2e-4, lr_scheduler: cosine, warmup_steps: 100, max_seq_len: 2048, save_steps: 500, eval_steps: 100 } }这里面的参数我挑几个重点解释一下。lora_r16在7B模型上是一个比较折中的选择r太小模型学不动r太大LoRA省显存的意义就弱了。batch_size和gradient_accumulation_steps的配合是为了在显存有限的情况下得到更大的等效batch size4 x 4等于等效32的batch size。learning_rate用2e-4而不用全参微调常用的1e-5是因为LoRA本身的参数量小需要相对高一点的学习率才能有效更新。这些参数不是死的。同样一份数据换一个基础模型就要重新调。我建议第一次跑先拿一个小数据集验证链路确认loss趋势正常再放全量数据长时间跑。5. 常见问题与排查技巧实录5.1 连接拒绝socket文件找不到还是权限不够“Connection refused”大概是我在这个架构里见过最多的报错。它有两种常见原因。第一种socket文件根本没有生成。检查方法很简单ls -l /run/microduck/如果文件不存在说明对应的守护进程还没起来或者起来了但bind失败。看日志文件就行日志里会写清楚bind失败的原因。第二种socket文件存在但你连不上。这种情况绝大多数是权限问题。Unix socket的连接权限由socket文件的属主和权限位控制如果你用A用户启动服务却用B用户去连接sock文件权限是0660、属主是AB用户就没有访问权。排查时先看属主ls -ln /run/microduck/duckctl.sock再看当前用户whoami id两边一对照就知道是不是权限不匹配。我自己后来统一用microduck系统账号跑所有服务所有外部请求也通过这个账号执行权限问题基本绝迹。5.2 请求超时长任务不该占用同步响应有一次集成方反馈“训练请求发出去几十秒都没返回”我第一反应是训练卡死了结果去查日志发现训练根本没开始。真正的原因是我的服务端实现有bugtrain.create在把任务塞进队列之后又顺手做了一次数据集校验而那个校验要扫描整个数据集文件7GB的文件扫描了几十秒把整个RPC响应拖住了。这个问题的解法就是我在协议设计里说的训练类方法必须“快进快出”提交就是提交校验放后台做状态通过查询接口暴露。我后来在duckdata里加了数据校验的缓存第一次扫描后生成校验快照后续请求直接读快照几百毫秒就能完成。另外一个常见的超时点出现在socket.recv上。客户端发完请求后如果服务器处理慢客户端就会一直阻塞在recv上。我建议客户端侧用settimeout把读取超时设成30秒避免无限挂起。5.3 残留pidfile与僵尸守护进程守护进程最阴间的故障是“pidfile还在但进程已经死了”。我的duckmon有一次检测到duckinfer“假死”心跳断了好几个周期但pidfile还躺在目录里。查了半天发现是duckinfer显存不足触发了OOM kill内核直接把进程杀了pidfile自然没机会被清理。所以我对pidfile的使用原则是只把它当作“参考”而不是“真相”。在判断进程是否存活时应该配合pidfile里的PID去查/proc/pid/目录是否存在或者干脆发一个心跳查询RPC确认进程真实状态。后来我在duckctl的进程管理逻辑里加了这样一个检查步骤发现问题就自动清掉残留pidfile并重新拉起服务这才彻底解决了僵尸态问题。5.4 多进程同时加载模型的资源竞争一个我刚开始没预料到的问题ducktrain训练完一个模型duckinfer推理另一个模型两个进程同时读取同一个基础模型文件时磁盘IO和内存压力骤增直接导致机器卡顿。排查之后定位到根因是模型文件先被操作系统page cache缓存在内存里多进程并发读同一份文件会加剧缓存竞争。解法有两个层面。第一错峰加载duckinfer在训练开始前先不加载模型等到训练稳定运行后再加载第二把基础模型文件放到SSD上并在加载时通过posix_fadvise禁用page cache预读减少对page cache的抢占。这些策略最终都落到架构约定里ducktrain启动训练前向duckctl报告“我要占用显存资源”duckctl协调duckinfer暂时释放GPU内存。这样一来训练和推理虽然在同一张卡上但不会发生激烈的资源打架。5.5 排查命令与错误码速查最后把我的排查命令和错误码整理成一张速查表方便你直接用错误码含义常见原因与处理-32700JSON解析错误客户端发的不是合法JSON检查消息格式和换行符-32600无效请求缺少method字段或jsonrpc字段-32601方法不存在method拼写错误或服务端未注册该方法-32603内部错误服务端handler抛出异常查看服务日志定位1001数据集不存在duckdata扫描不到指定的数据集名检查数据目录1002显存不足GPU显存被其他进程占用用nvidia-smi查看并释放1003任务重复相同参数的任务已在运行直接复用现有task_id1004模型加载中duckinfer正在加载模型稍后重试1005训练任务不存在task_id拼错或任务已被清理排查时我常用的命令组合是# 查看进程存活状态 ps aux | grep duck # 查看所有socket文件 ls -l /run/microduck/ # 查看训练服务日志尾部 tail -f /var/log/microduck/ducktrain.log # 查看GPU显存情况 nvidia-smi如果日志显示请求已经到了服务端但没返回那就是handler执行逻辑的问题往业务代码里查如果日志里压根没有请求记录那就是网络层或权限层的问题往socket路径和权限上查。这套二分法基本能覆盖90%的故障场景。这套守护进程军团架构我重构了三次才稳定下来。最初图省事把训练和推理塞进同一个进程结果推理请求高峰期训练卡到loss不降后来拆开了又因为socket路径和权限没规划好时不时冒出连接拒绝的问题。现在这版跑了一个多月唯一一次服务中断还是因为机器断电其余时间都稳如老狗。如果你也要给Microduck做类似的守护进程封装我的建议是先拆训练和推理这两个核心服务数据服务和监控后续再加不要一上来就想把所有模块全部微服务化。先把训练的稳定性守住再谈军团扩张。最后一句话Unix socket上的JSON-RPC这套组合性能不是最高的但它是本地多进程架构里“开发效率、调试体验、安全隔离”三者最均衡的方案值得你认真用一次。