qwen serve Daemon 文件日志器:从设计到落地的持久化诊断方案
【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-code
qwen serve是 qwen-code 的常驻服务模式,用于承载 SDK、桌面端与本地进程对 ACP 会话的访问。本文以仓库中的设计文档 docs/design/2026-05-26-daemon-logger-design.md 为主线,结合其已落地的实现,完整讲解 Daemon 级文件日志器的动机、架构、公开 API、日志格式、配置方式与容错机制。读完你将掌握:daemon 日志文件写在哪里、latest软链如何辅助tail -f、QWEN_DAEMON_LOG_FILE等环境变量的精确语义,以及实现相对设计文档新增的轮转、保留、并发写锁与降级模式等进阶能力。
1. 问题背景:为什么 daemon 级诊断需要落盘
qwen serve把 daemon 级别的诊断信息(生命周期事件、路由错误、ACP 子进程 stderr)输出到process.stderr。这在 systemd / Docker 场景下没有问题,但对 SDK、桌面端和本地 daemon 场景却很脆弱:
- 当客户端看到
POST /session/:id/prompt返回 HTTP 500 时,路由、会话与调用栈上下文已经随进程消失,除非操作者事先手动重定向了 stderr; - 现有的
createDebugLogger(位于 packages/core/src/utils/debugLogger.ts)是会话作用域的:它要求存在活跃的DebugLogSession,并写入${runtimeBaseDir}/debug/<sessionId>.txt。而 serve daemon 在任何会话存在之前就已启动,因此 daemon 级调用会静默失效;同时复用它会破坏按会话维护的debug/latest语义。
设计结论是:新增一个 daemon 专属的文件 sink,对现有 stderr 行为做纯增量扩展(additive),让 daemon 诊断在没有 shell 重定向的情况下也能留存。
2. 设计范围与非目标
In scope
- 每个
runQwenServe进程初始化一次的 logger; - 文件路径:
${QWEN_RUNTIME_DIR 或 ~/.qwen}/debug/daemon/<daemon-id>.log,追加模式; - 汇聚以下四类输出:
runQwenServe.ts的生命周期 / 关闭 / 信号消息;server.ts中sendBridgeError的路由错误;bridge.ts的writeServeDebugLine(当QWEN_SERVE_DEBUG开启时);spawnChannel.ts的 ACP 子进程 stderr 转发;
- 通过
QWEN_DAEMON_LOG_FILE=0|false|off|no退出(opt-out); - daemon 目录下的
latest软链,便于tail -f; - serve CLI 文档补充说明。
Out of scope(issue 明确的非目标)
- 不替代 OpenTelemetry,也不新增 daemon 追踪;
- 不做结构化企业级日志导出(由 issue #2014 另行负责);
- 不轮转、不删除既有的会话 debug 日志;
- daemon 日志自身的轮转与大小上限在原始设计中明确延后(后续 PR 实现,见下文第 11 节)。设计阶段仅约定:若既有文件异常大,启动时输出一条 stderr 警告,不做自动处理。
3. 架构与模块边界
设计将改动收敛在几个文件内,依赖图保持不变:
| 层 | 新增/变更 | 职责 |
|---|---|---|
packages/cli/src/serve/daemonLogger.ts | 新增 | sink:初始化、格式化、追加写文件、tee 到 stderr、flush、维护latest软链 |
packages/cli/src/serve/runQwenServe.ts | 变更 | 启动时初始化 logger;用daemonLog.*替换生命周期writeStderrLine;关闭时await flush();把onDiagnosticLine传入 bridge |
packages/cli/src/serve/server.ts | 变更 | sendBridgeError(...)改为经daemonLog.error(...)路由 |
packages/acp-bridge/src/types.ts(BridgeOptions) | 变更 | 新增可选回调onDiagnosticLine?: (line, level?) => void |
packages/acp-bridge/src/bridge.ts:writeServeDebugLine | 变更 | 注入回调时,同一行 tee 一份 |
packages/acp-bridge/src/spawnChannel.ts | 变更 | 子进程 stderr 转发器把每行前缀内容 tee 进onDiagnosticLine |
设计意图:daemonLogger.ts单文件、cli 局部、无全局单例;acp-bridge对 cli 保持无知,只看到一个回调。
在落地实现中,模块边界与设计一致,但文件命名变为daemon-logger.ts(kebab-case),回调类型被正式命名为DiagnosticLineSink并定义在 packages/acp-bridge/src/bridgeOptions.ts:
export type DiagnosticLineSink = ( line: string, level?: 'info' | 'warn' | 'error', ) => void;BridgeOptions.onDiagnosticLine字段位于 packages/acp-bridge/src/bridgeOptions.ts,注释明确:省略时是 no-op,由 cli 的runQwenServe从 daemon logger 注入。
3.1 无全局单例
Logger 在runQwenServe内创建,通过闭包传给需要它的内部 serve 模块(或通过回调传给acp-bridge)。理由有二:
- 与
BridgeOptions既有的依赖注入方式保持一致; - 避免
debugLogger历史上遭遇的跨测试状态泄漏(仓库为此保留了resetDebugLoggingState())。
4. 日志路径与 Daemon ID
设计约定的路径推导:
- 路径 =
Storage.getGlobalDebugDir() + '/daemon/<daemon-id>.log',即${QWEN_RUNTIME_DIR 或 ~/.qwen}/debug/daemon/<daemon-id>.log,复用运行时目录覆盖(环境变量、上下文)机制; daemon-id=serve-${pid}-${workspaceHash}:workspaceHash=crypto.createHash('sha256').update(boundWorkspace).digest('hex').slice(0, 8)——固定长度、文件名安全、同一 workspace 路径下稳定;pid用于区分同一 workspace 上的多个 daemon。
latest软链:~/.qwen/debug/daemon/latest→ 当前进程的日志文件,初始化时用updateSymlink助手更新,失败仅记录不致命;- 文件模式:
'a'(O_APPEND | O_CREAT),重启后旧文件保留,便于事后取证。
需要指出:实现与设计在此处有演进。落地的 packages/cli/src/serve/daemon-logger.ts 中:
- daemon-id 简化为
daemon:${pid}(见computeDaemonId,第 325-327 行),稳定路径固定为daemon/daemon.log; - workspace 信息仍保留在首行引导记录里:
workspace=<boundWorkspace> workspaceHash=<8位hex>; - 每次进程启动生成 32 位 hex 的
runId(crypto.randomBytes(16).toString('hex')),写进日志行与状态查询,作为运行身份标识。
5. 公开 API
设计文档给出了完整接口契约,实现基本忠实还原(daemon-logger.ts):
export interface DaemonLogContext { route?: string; sessionId?: string; clientId?: string; childPid?: number; channelId?: string; [key: string]: unknown; } export interface DaemonLogger { info(message: string, ctx?: DaemonLogContext): void; warn(message: string, ctx?: DaemonLogContext): void; /** err.stack 以缩进续行追加在消息后;err 与 ctx 相互独立、均可省略。 */ error(message: string, err?: Error | null, ctx?: DaemonLogContext): void; /** * 仅写文件的 tee:调用方本身已在写 stderr(ACP 子进程 stderr 转发器、 * writeServeDebugLine)时使用。行会以标准前缀追加到 daemon 日志, * 但不再回显到 stderr(避免操作者输出翻倍)。 */ raw(line: string, level?: 'info' | 'warn' | 'error'): void; /** daemon 日志文件的绝对路径。 */ getLogPath(): string; getDaemonId(): string; /** 排空待写追加。由 runQwenServe 关闭处理器调用。 */ flush(): Promise<void>; } export interface InitDaemonLoggerOptions { boundWorkspace: string; pid?: number; // 默认 process.pid now?: () => Date; // 默认 () => new Date() stderr?: (line: string) => void; // 默认 writeStderrLine baseDir?: string; // 默认 Storage.getGlobalDebugDir() } export function initDaemonLogger(opts: InitDaemonLoggerOptions): DaemonLogger;initDaemonLogger同步执行步骤(设计约定):
- 计算
daemonId与日志路径; mkdirSync(parentDir, { recursive: true })——失败 → 返回 no-op logger + 一条 stderr 警告,启动继续;appendFileSync(path, '<first line>\n', { flag: 'a' })同步写入daemon started pid=<pid> workspace=<boundWorkspace> version=<cli version>。这同时充当可写性探测:EACCES/ENOSPC 时降级为 no-op logger + 一条 stderr 警告;- 尽力更新
latest软链(吞掉错误); - 返回 logger,后续
info/warn/error/raw调用入队异步fs.promises.appendFile。
若QWEN_DAEMON_LOG_FILE为0|false|off|no之一,initDaemonLogger在任何文件系统调用前短路为 no-op logger。
实现变更说明:落地的initDaemonLogger变成了异步函数(返回Promise<DaemonLogger>,见 daemon-logger.ts),因为稳定/回退家族分配与租约获取需要异步 I/O。但“失败可见于启动、不深埋于首次错误”的设计意图被保留,并通过DaemonLoggerStatus显式暴露:
export interface DaemonLoggerStatus { runId: string; mode: 'stable' | 'fallback' | 'stderr-only'; health: 'ok' | 'degraded'; issues: readonly DaemonLogIssue[]; droppedRecords: number; droppedBytes: number; }getStatus()(实现新增)被run-qwen-serve.ts用于状态查询接口(见 run-qwen-serve.ts),health === 'degraded'时会输出相应告警。
6. 日志行格式
设计要求与debugLogger.buildLogLine视觉对齐:
2026-05-26T03:14:15.926Z [ERROR] [DAEMON] [trace_id=... span_id=...] route=POST /session/:id/prompt sessionId=abc clientId=xyz daemon failed to ... at fn (file.ts:42:7) at ...格式要点:
- 时间戳:ISO 8601,UTC(
toISOString()); - 级别:
INFO | WARN | ERROR。初始无 DEBUG——QWEN_SERVE_DEBUG的内容以INFO级别经raw()汇入; - 标签:字面量
DAEMON; - 追踪上下文:
trace.getActiveSpan()可用时输出,逻辑与debugLogger.getActiveSpanTraceContext相同; - 上下文字段:
key=value形式,固定顺序route → sessionId → clientId → childPid → channelId,随后附加键按字典序排列;值含空白或=时用JSON.stringify加引号; - 错误栈:以缩进续行追加在消息之后;
raw(line, level):在标准前缀<timestamp> [<LEVEL>] [DAEMON]之后原样写入,不做额外加工。
实现中有两个实现级细节值得注意(均有测试覆盖):
- 文件行与 stderr 行略有差异:写入文件的行会额外携带
runId=<runId> pid=<pid>(buildDaemonFileLogLine,第 185-200 行),stderr 行则保持简洁。runId/pid是保留上下文键,无法被调用方伪造(测试reuses the stable path across restarts and keeps run identity immutable验证了这一点); - trace 键防伪造:
trace_id/span_id属于保留键,会从上下文渲染中剔除,避免调用方注入伪造的追踪信息(见renderCtx与测试stays pure in an active span and filters reserved trace keys)。
Tee 语义(重要)
info / warn / error同时写 daemon 日志文件和 stderr(经注入的 stderr writer)。替换原先writeStderrLine(...)的调用方直接使用它们即可,无需再单独调 stderr;raw仅写文件。用于 ACP 子进程 stderr 转发器与writeServeDebugLine——这两处调用方本就通过既有路径写 stderr,若再回显会淹没操作者输出。
测试 daemon-logger.test.ts 验证:raw()写入带前缀的行、不触发 stderr tee、也不捕获环境 trace 上下文。
7. 启动 / 关闭流程
设计伪代码:
runQwenServe(opts): ... daemonLog = initDaemonLogger({ boundWorkspace }) writeStderrLine(`qwen serve: daemon log → ${daemonLog.getLogPath()}`) // 启动横幅仅写 stderr,避免“自指”成环 bridge = createHttpAcpBridge({ ..., onDiagnosticLine: (line, level) => daemonLog.raw(line, level), }) app = createServeApp({ ..., daemonLog }) // 注入给 sendBridgeError shutdownHandler(signal): daemonLog.warn(`shutdown signal=${signal}`) await drainBridge() await daemonLog.flush() process.exit(0)三个关键取舍:
- 启动横幅仅走 stderr:关于路径的那行如果写进日志自身会形成自指循环;
initDaemonLogger同步:任何失败在启动瞬间即可见,而不是埋在第一次错误之后;- 关闭时
flush()是process.exit前最后一个 await 步骤:SIGKILL 天然无法 flush,设计明确接受这一点。
实现中该流程完整保留:run-qwen-serve.ts在启动阶段await initDaemonLogger({ boundWorkspace, baseDir: daemonLogBaseDir, ... })(run-qwen-serve.ts),随后输出qwen serve: daemon log → <path>(run-qwen-serve.ts),bridge 构造时注入onDiagnosticLine: (line, level) => daemonLog.raw(line, level)(run-qwen-serve.ts),关闭路径上daemonLog.flush()先行(run-qwen-serve.ts)。
8. 覆盖范围:哪些输出进了 daemon 日志
设计给出覆盖对照表,核心原则是每一条既有 stderr 写入都被保留,daemon 日志是纯增量:
| 来源 | 现状 | 改造后 |
|---|---|---|
runQwenServe.ts生命周期 / 信号 / 配置警告 | writeStderrLine(...) | daemonLog.info \| warn(...)(stderr 仍发生,由 daemonLog tee) |
runQwenServe.ts"listening on URL"(stdout) | writeStdoutLine(...) | 不变——操作脚本解析 stdout |
server.ts:sendBridgeError | writeStderrLine(...)带 route/sessionId | daemonLog.error(msg, err, { route, sessionId, ... }) |
bridge.ts:writeServeDebugLine(QWEN_SERVE_DEBUG) | writeStderrLine('qwen serve debug: ...') | tee 到onDiagnosticLine(line, 'info') |
spawnChannel.ts子进程 stderr | process.stderr.write(prefix + line + '\n') | 同时onDiagnosticLine(prefix + line, 'warn') |
| CLI 用法 / argparse 错误(早期校验) | writeStderrLine(...) | 不变(此时 logger 可能尚未创建) |
实现印证:server.ts中sendBridgeError被包装为注入daemonLog的版本(server.ts);spawnChannel.ts的 stderr 转发器把每一行(含截断保护)以'warn'级别回调onDiagnosticLine(spawnChannel.ts);bridge.ts中writeServeDebugLine在QWEN_SERVE_DEBUG生效时以'info'级别把qwen serve debug: <msg>汇入回调(bridge.ts)。
9. 写入路径与 Flush
设计约定的写路径:
- 内部维护一条
Promise<void>链:this.pending = this.pending.then(() => fs.promises.appendFile(...)); - 每次
info/warn/error/raw入队一次追加(文件);info/warn/error还同步调用注入的 stderr writer; - stderr 写入顺序保持(同步,先于入队);文件追加按入队顺序最终一致;
- 写失败置内部
degraded标志并一次性输出 stderr 警告,后续调用仍尝试写但不再维护计数; flush()返回当前尾部的 promise;- 无缓冲层:每次调用 = 一次
appendFile。路由错误 + 生命周期量级很低,微批处理属过早优化。
实现保留了 promise 链与 degraded 标志,并追加了背压与丢记录会计:当待写字节超过maxPendingBytes(默认 4 MiB)时,文件副本会被丢弃(queue_overflowissue + 一次性 stderr 提示),丢弃计数进入getStatus(),并在容量恢复后以一条 WARN 汇总记录(daemon file log records dropped)补记损失。
10. 配置:环境变量语义
| 环境变量 | 行为 |
|---|---|
QWEN_DAEMON_LOG_FILE=0\|false\|off\|no | initDaemonLogger返回 no-op;tee 为 no-op;stderr 不变 |
QWEN_DAEMON_LOG_FILE=<其他任意值>或未设置 | 启用(默认) |
QWEN_RUNTIME_DIR=<path> | 重定位~/.qwen根目录,daemon 日志随之移动(既有语义) |
QWEN_SERVE_DEBUG=1 | 既有行为——激活writeServeDebugLine;其行现在也 tee 进 daemon 日志 |
QWEN_DAEMON_LOG_FILE有意与QWEN_DEBUG_LOG_FILE分离,这样关闭按会话的 debug 日志不会连操作者的 daemon 日志一起关掉(反之亦然)。
实现中isOptedOut()(daemon-logger.ts)对值做 trim + 小写匹配,因此False、OFF等变体同样生效;opt-out 路径返回mode: 'stderr-only'的 logger(daemon-logger.test.ts 以参数化用例验证了全部取值)。
11. 实现落地:设计之外的工程增强
原始设计将“轮转 / 大小上限”列为非目标并延后。仓库中的后续设计 docs/design/2026-07-15-daemon-log-stable-rotation.md 与最终实现补齐了这块,使 daemon 日志从“无限增长的取证文件”演进为有界、可并发、自愈的持久化存储。以下均可在 daemon-logger.ts 与 daemon-logger.test.ts 中找到实现与测试证据:
11.1 有界存储:轮转 + 保留 + 记录截断
默认策略(DEFAULT_POLICY,第 75-86 行):
{ maxBytes: 10 * 1024 * 1024, // 单个活跃日志上限 10 MiB maxArchives: 4, // 归档文件保留上限 4 个 maxRecordBytes: 256 * 1024, // 单条记录上限 256 KiB maxPendingBytes: 4 * 1024 * 1024, // 待写队列字节上限 // ... 租约与维护相关预算(见 11.2) }- 轮转:活跃文件超过
maxBytes时,先重命名为归档archive/daemon-<12位代次>-<时间戳>-<8位随机hex>.log,再创建新的daemon.log; - 保留:归档超过
maxArchives时删除最旧文件;启动时若发现归档超限也会补做清理; - 记录截断:单条记录超过
maxRecordBytes时,在UTF-8 边界安全处截断并追加[truncated originalBytes=<N>]标记,绝不产生乱码(\ufffd)——测试truncates a file record on a UTF-8 boundary without truncating stderr用 200 个“你”字验证,且 stderr 侧始终收到完整消息。
11.2 多 daemon 并发:租约(lease)与 stable / fallback 双模式
同一 workspace 可能同时存在多个 daemon 进程。实现引入proper-lockfile租约机制:
- 首个 daemon 获得
.stable-writer.lock(1 秒获取预算),成为stable写入者,固定写daemon/daemon.log; - 抢锁失败(
ELOCKED)的后续 daemon 走fallback:在daemon/runs/run-<runId>/daemon.log下独立写,维护用.maintenance.lock,家族所有权用.owner.lock,并在runs/recent-fallback中记录最近回退目录; latest软链始终指向 stable 文件;回退家族在关闭时做保留清理,只保留最新非活跃家族;- 租约被
onCompromised判定受损时,文件写被“中毒”(poisoned)停用,转为降级模式,避免多写者互相覆盖。
测试keeps latest on stable while a concurrent logger uses fallback(daemon-logger.test.ts)完整验证了这一并发场景。
11.3 安全权限与状态可见性
- 文件
0o600、目录0o700(FILE_MODE/DIRECTORY_MODE),避免日志与锁文件被同机其他用户读取; getStatus()让run-qwen-serve.ts能把runId / logMode / logHealth / logIssues / logDroppedRecords / logDroppedBytes暴露给状态接口(run-qwen-serve.ts),运维可程序化感知降级与丢记录。
12. 错误处理矩阵
| 场景 | 行为 |
|---|---|
initDaemonLoggermkdir/open 失败 | no-op logger + 一条 stderr 警告;daemon 启动继续;文件无内容但 stderr 仍工作 |
| 单次追加失败 | 翻转 degraded 标志(write_failed),一次性 stderr 警告,继续尝试 |
flush()被拒 | 关闭处理器捕获并记到 stderr;不阻塞退出 |
latest软链失败 | 吞掉;主写入不受影响 |
| 轮转/保留失败 | rotation_failed/retention_failedissue + 一次性警告;按rotationRetryIntervalMs(默认 60s)重试 |
| 队列溢出 | queue_overflowissue + 一次性警告;丢文件副本并会计,恢复后补 WARN 汇总 |
| 租约受损 | lease_compromisedissue;文件写中毒停用,降级 stderr-only |
13. 测试覆盖
设计文档规划的测试在 packages/cli/src/serve/daemon-logger.test.ts(1569 行)中全面落地,并随实现增强:
- 行格式:INFO/WARN/ERROR、固定上下文顺序、附加键字典序、含空白/
=值的 JSON 引号、错误栈缩进续行、栈缺失时回退name: message; - opt-out:
0/false/off/no(含大小写、空白变体)→ 不创建文件、stderr-only模式、仍支持 trace 上下文注入; - init:路径与 daemon-id 推导、启动记录含
runId/pid/workspace/workspaceHash、目录/文件/锁文件权限断言、mkdir 失败降级; - raw / tee 语义:raw 仅文件、不 tee stderr;info/warn/error 文件 + stderr 双写;
- trace 上下文:采样且 recording 的 span 注入、未采样/无效/异常 span 省略、查找抛错不影响日志、
trace_id/span_id防伪造; - flush:50 条并发入队后
flush()保证全部落盘; - latest 软链:创建、更新、stable 并发下的归属;
- 有界存储:重启复用稳定路径且 runId 不可伪造、UTF-8 边界截断、轮转与严格归档上限;
- 降级:append 失败 → 中毒 +
write_failed+ 丢记录计数。
此外runQwenServe/server/ acp-bridge 的测试分别验证了启动记录写入、关闭前 flush、路由错误经daemonLog.error携带正确route/sessionId,以及onDiagnosticLine在QWEN_SERVE_DEBUG=1与子进程 stderr 转发两条路径上的回调(测试用捕获型 fake,不触碰文件系统)。
14. 文档、回滚与验收标准
文档:设计约定在 serve CLI 文档中新增 “Daemon log file” 一节,覆盖路径、daemon-id 格式、latest软链、QWEN_DAEMON_LOG_FILEopt-out,以及与按会话debug/<sessionId>.txt的区别;packages/cli/src/serve/下若有 README 则同步补充。
回滚:纯增量改动,回滚即 revert 提交——删除daemon-logger.ts及其测试,还原runQwenServe.ts生命周期 /sendBridgeError/ bridge /spawnChannel改动,移除BridgeOptions.onDiagnosticLine。无磁盘状态需要清理,既存 daemon 日志文件成为无害的孤儿文件。
验收标准(issue 原文,逐条落实):
| 标准 | 实现方式 |
|---|---|
qwen serve无需 shell 重定向即创建/追加 daemon 日志 | initDaemonLogger启动即打开文件 |
HTTP 500(POST /session/:id/prompt)可在 daemon 日志中关联 | sendBridgeError写入route=+sessionId= |
| ACP 子进程 stderr 行也进 daemon 日志 | spawnChannel经onDiagnosticLinetee |
| 首个会话之前、全部会话关闭之后日志仍工作 | 非会话作用域,随 daemon 生命周期存活 |
| 既有 stderr 行为不变 | 全部写入为增量;无writeStderrLine调用被移除而不留等价物 |
| 日志路径与 opt-out 有文档 | 文档章节 |
15. 开放问题与后续方向
设计保留了两个非阻塞的后续项,其中第一个已在实现中尘埃落定:
latest软链位置:定于~/.qwen/debug/daemon/latest(实现确认,测试creates daemon/latest pointing to the current log验证);- JSON 行输出(如
QWEN_DAEMON_LOG_FORMAT=json)作为未来 flag 的可能性:仍超出当前 PR 范围,结构化导出由 issue #2014 负责。
结语
从设计文档到落地实现,daemon 文件日志器始终围绕一条主线:让qwen serve的 daemon 级诊断在无 shell 重定向、无会话上下文的场景下依然可留存、可关联、可取证。设计确立了路径、API、格式与纯增量原则;实现则在此基础上补齐了轮转保留、并发租约、降级自愈与状态可见性,最终通过 1569 行测试固化行为。对 SDK / 桌面端 / 本地 daemon 的运维者而言,~/.qwen/debug/daemon/daemon.log加上tail -f ~/.qwen/debug/daemon/latest,即是排查路由 500 与子进程异常的可靠起点。
【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-code
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考