news 2026/9/13 6:49:38

如何用 Dual Output 让外部程序订阅 Qwen Code 交互会话的结构化事件流?

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
如何用 Dual Output 让外部程序订阅 Qwen Code 交互会话的结构化事件流?

如何用 Dual Output 让外部程序订阅 Qwen Code 交互会话的结构化事件流?

【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-code

Dual Output 是 Qwen Code 交互 TUI 的一个旁路(sidecar)模式:Qwen Code 在stdout上照常渲染 TUI 的同时,把结构化的 JSON 事件流写到另一条独立通道上,供外部程序——IDE 扩展、Web 前端、CI 流水线、自动化脚本——订阅。它还提供一条反向通道:外部程序向一个被 TUI 监视的文件写入 JSONL 命令,就可以像人坐在键盘前一样提交 prompt、响应工具权限请求。

整个功能完全可选:不带任何 Dual Output 参数时,TUI 行为与之前完全一致,没有额外 I/O。本文以「让一个外部 Node/Shell 程序订阅会话事件流并反向注入命令」为目标,走完从选通道、启动、验收到处理异常的全程。前提是本机已安装并可以使用qwen命令行(README 中给出的安装方式之一:npm install -g @qwen-code/qwen-code@latest,需要 Node.js 22 或更高版本)。

先选输出通道:--json-fd还是--json-file

Dual Output 的三个参数如下:

参数类型用途
--json-fd <n>数字,n >= 3把 JSON 事件写到文件描述符n。调用方必须通过 spawn 的stdio配置或 shell 重定向提供该 fd
--json-file <path>路径把 JSON 事件写到文件。路径可以是普通文件、FIFO(命名管道)或/dev/fd/N
--input-file <path>路径监视该文件,接收外部程序写入的 JSONL 命令

--json-fd--json-file互斥;fd 0、1、2 会被拒绝,防止破坏 TUI 自己的输出。两者怎么选,取决于你的外部程序如何托管 TUI:

嵌入方式使用
child_process.spawn+ 普通stdio--json-fd
node-pty/bun-pty/ 任何 PTY 宿主--json-file
Shell 重定向 / 手动管道测试两者皆可
CI 日志收集(普通文件,退出后读取)--json-file
同主机上追求最低延迟--json-file+ FIFO

文档给出的判断规则很直接:如果你需要 TUI 正确渲染,就需要 PTY,因此需要用--json-file原因是 PTY 封装层(node-pty等)的 API 不接受stdio数组,且底层forkpty(3)/login_tty会在exec前主动关闭父进程中所有>= 3的 fd——额外的 fd 无法被继承。而文件路径只是普通 CLI 参数,能穿过任何 spawn 模型。--json-fd适合那种干脆丢弃 stdout 的纯程序化包装器。

最短主路径:普通文件 + 两个终端

启动时同时打开输出与输入两条通道:

touch /tmp/qwen-events.jsonl /tmp/qwen-input.jsonl qwen \ --json-file /tmp/qwen-events.jsonl \ --input-file /tmp/qwen-input.jsonl

在第二个终端里 tail 事件流,确认通道已建立:

tail -f /tmp/qwen-events.jsonl

事件通道上收到的第一个事件永远是system/session_start(bridge 构造时发出),用它把通道与 session id 关联起来,再等待其它事件:

{ "type": "system", "subtype": "session_start" }

看到这条就说明订阅链路已通。之后在第三个终端向运行中的 TUI 注入一条 prompt:

echo '{"type":"submit","text":"Explain this repo"}' >> /tmp/qwen-input.jsonl

这条 prompt 会以用户亲键输入的方式出现在 TUI 中,流式响应同步镜像到/tmp/qwen-events.jsonl。这就是「外部程序订阅 + 反向控制」的最小闭环。

可选分支:用 FIFO 降低事件输出延迟

FIFO 没有磁盘 I/O,当读写双方都在同一台主机上时延迟更低:

mkfifo /tmp/qwen-events.jsonl touch /tmp/qwen-input.jsonl qwen \ --json-file /tmp/qwen-events.jsonl \ --input-file /tmp/qwen-input.jsonl # TUI 立即可启动 —— 不需要先启动 reader # 第二个终端,随时连接: cat /tmp/qwen-events.jsonl

bridge 以O_RDWR | O_NONBLOCK打开 FIFO,所以没有 reader 时也不会阻塞,事件先缓存在内核管道缓冲区。两个注意点:

  • --input-file只接受普通文件,不接受 FIFO——watcher 依赖stat.size检测新数据,而 FIFO 的 size 恒为 0。
  • 如果 FIFO 始终没有 reader,内部缓冲区超过 1 MB 后 bridge 自动禁用,TUI 继续正常运行。

理解订阅到的事件流:schema 与握手

事件以 JSON Lines 输出(每行一个对象),schema 与非交互模式--output-format=stream-json相同,且includePartialMessages恒为开启。协议版本 2 会把文本型tool_result.content在 JSON 序列化后限制在 65,536 个 UTF-8 字节以内,超限值会变成确定性的头/尾预览——这是字段限制,不是整个 JSONL 帧的大小限制。

按生命周期,你会依次看到这几类事件(以下为文档给出的结构示例):

// 会话生命周期:第一个事件,用于关联 session id { "type": "system", "subtype": "session_start", "uuid": "...", "session_id": "...", "data": { "session_id": "...", "cwd": "/path/to/cwd" } } // 进行中的 assistant 回合的流式事件 { "type": "stream_event", "event": { "type": "message_start", "message": { ... } }, ... } { "type": "stream_event", "event": { "type": "content_block_delta", "index": 0, "delta": { "type": "text_delta", "text": "Hello" } }, ... } { "type": "stream_event", "event": { "type": "message_stop" }, ... } // 完成的消息 { "type": "user", "message": { "role": "user", "content": [...] }, ... } { "type": "assistant", "message": { "role": "assistant", "content": [...], "usage": { ... } }, ... } // 权限控制平面(仅在工具需要审批时出现) { "type": "control_request", "request_id": "...", "request": { "subtype": "can_use_tool", "tool_name": "run_shell_command", "tool_use_id": "...", "input": { "command": "rm -rf /tmp/x" }, "permission_suggestions": null, "blocked_path": null } } { "type": "control_response", "response": { "subtype": "success", "request_id": "...", "response": { "allowed": true } } }

control_response无论审批决定是在 TUI 的原生确认界面做出的,还是由外部confirmation_response做出的,都会发出——所有观察者都能看到最终结果。

订阅端还应把system/session_end当作干净退出信号;如果 TUI 在session_end之前崩溃,输出流会直接关闭(下一次写入时表现为EPIPE),两种路径都要处理。

反向通道:两种输入命令

--input-file接受两种命令形态:

// 向 prompt 队列提交一条用户消息 { "type": "submit", "text": "What does this function do?" } // 响应一条挂起的 control_request { "type": "confirmation_response", "request_id": "...", "allowed": true }

行为上有几条对订阅端很关键的规则:

  • submit进入队列。如果 TUI 正在响应中,命令会在 TUI 回到空闲状态时自动重试。
  • confirmation_response立即分发、从不排队,因为工具调用是阻塞的,响应必须直达底层的onConfirm处理器。
  • 哪一侧先批准工具,哪一侧生效;另一侧迟到的响应会被无害地丢弃。
  • 无法解析为 JSON 的行会被记录日志并跳过,不会让 watcher 停止。

远程审批工具调用时,一个可用的演练流程(文档 POC 3):

# 终端 A —— 只观察 control_request mkfifo /tmp/qwen-out.jsonl touch /tmp/qwen-in.jsonl (cat /tmp/qwen-out.jsonl \ | jq -c 'select(.type == "control_request")') & # 终端 B qwen --json-file /tmp/qwen-out.jsonl --input-file /tmp/qwen-in.jsonl # 让 Qwen 做一件需要审批的事,例如"run `ls -la /tmp`"。 # 终端 A 会出现 control_request,复制其中的 request_id,然后在第三个终端: echo '{"type":"confirmation_response","request_id":"<paste-id>","allowed":true}' \ >> /tmp/qwen-in.jsonl # TUI 的确认提示消失,工具开始执行

注意命令中的<paste-id>需要替换为你从control_request事件里复制到的实际request_id。如果回了一个未知request_id,bridge 会在输出通道上发出一条control_response供消费者记录或重试:

{ "type": "control_response", "response": { "subtype": "error", "request_id": "...", "error": "unknown request_id (already resolved, cancelled, or never issued)" } }

程序化订阅:Node 宿主进程示例

对「父进程 spawn Qwen Code、tail 事件、按自己节奏注入 prompt」这一最真实的形态,文档给出了两种 spawn 写法。

fd 方式(child_process.spawn,不经过 PTY):

import { spawn } from 'node:child_process'; import { openSync } from 'node:fs'; const eventsFd = openSync('/tmp/qwen-events.jsonl', 'w'); const child = spawn( 'qwen', ['--json-fd', '3', '--input-file', '/tmp/qwen-input.jsonl'], { stdio: ['inherit', 'inherit', 'inherit', eventsFd] }, );

此时 TUI 仍持有用户终端的 stdio 0/1/2,嵌入方在 fd 3 背后的文件上读结构化事件,向/tmp/qwen-input.jsonl追加 JSONL 行来推命令。

PTY 方式(node-pty,TUI 需要正确渲染时):

import { spawn } from 'node-pty'; const pty = spawn( 'qwen', [ '--json-file', '/tmp/qwen-events.jsonl', '--input-file', '/tmp/qwen-input.jsonl', ], { cols: 120, rows: 40 }, );

子进程自己打开事件文件写入,嵌入方用fs.watch+ 增量读取 tail 同一路径。

事件处理的骨架(取自文档的demo-embedder.ts示例,运行方式npx tsx demo-embedder.ts):

rl.on('line', (line) => { if (!line.trim()) return; const ev = JSON.parse(line); if (ev.type === 'system' && ev.subtype === 'session_start') { // 旧版本 Qwen Code 不保证发出 protocol_version,先做特性探测 const v = ev.data?.protocol_version ?? 0; if (ev.data?.supported_events?.includes('control_request')) { console.log('[embedder] permission control-plane available'); } } if (ev.type === 'assistant') { console.log('[embedder] assistant turn ended, tokens =', ev.message.usage?.output_tokens); } if (ev.type === 'system' && ev.subtype === 'session_end') { console.log('[embedder] session ended cleanly'); } });
// 2 秒后注入一条 prompt,如同用户键入 setTimeout(() => { appendFileSync( input, JSON.stringify({ type: 'submit', text: 'hello from embedder' }) + '\n', ); }, 2000);

完整的可运行 demo(POC 1–7)都在 dual-output 文档 中,从「只看事件流」到「失败演练」逐级递进,可直接复制执行。

settings.json 配置(长驻嵌入方)

对长期运行的嵌入方,每次启动都穿 CLI 参数并不方便。同一套通道可以写进settings.json的顶层dualOutput键:

// ~/.qwen/settings.json(用户级) // 或 <workspace>/.qwen/settings.json(工作区级) { "dualOutput": { "jsonFile": "/tmp/qwen-events.jsonl", "inputFile": "/tmp/qwen-input.jsonl", }, }

优先级规则:

  • CLI 参数优先于 settings:命令行传了--json-file /foo就会覆盖 settings 里的dualOutput.jsonFile
  • --json-fd没有 settings 等价项——fd 传递是 spawn 时机的问题,无法静态声明。
  • 参数和 settings 都没有时,Dual Output 保持关闭。

dualOutput配置项带requiresRestart: true(见 settingsSchema):bridge 在启动时构造一次,改动只在下次启动 Qwen Code 时生效。

失败模式与延迟边界

排查订阅端问题时,按文档列出的失败模式对号:

  • 坏 fd:传给--json-fd的 fd 未打开,或是 0/1/2——TUI 在stderr打印警告(如Warning: dual output disabled — fd 9999 not open),Dual Output 不启用,TUI 照常启动。
  • 坏路径--json-file的文件打不开——同样是stderr警告 + 无 Dual Output,TUI 照常启动。
  • 消费端断开:通道另一端 reader 消失(EPIPE)时,bridge 静默自禁用,TUI 继续运行,不重试。
  • FIFO 缓冲溢出:无 reader 的 FIFO 上,事件先在内核管道(Linux 约 64 KB)和 Node.js WriteStream 中缓冲;管道写满或内部缓冲超过 1 MB 后 bridge 自禁用并关闭 fd。这种情况下不会发出session_end——消费端应把「没有session_end的流关闭」视为异常终止。
  • 适配器异常:事件发射过程中的任何异常都会被捕获、记录并禁用 bridge;Dual Output 故障永远不会把 TUI 打崩。

延迟方面:--input-filefs.watchFile以 500 ms 间隔轮询,所以远程submit的最坏往返延迟约半秒——这是为了跨平台与文件系统(包括 macOS / 网络挂载)可移植而有意为之。输出通道没有轮询,事件随 TUI 发出同步写入。

多会话隔离时,文档建议把每会话的文件路径放在$XDG_RUNTIME_DIR下,或放在一个mkdtemp出来、权限0700的目录里。

验证清单

一次订阅链路算打通,需要依次看到:

  1. 事件通道第一行是{ "type": "system", "subtype": "session_start" }
  2. 通过--input-file注入submit后,TUI 出现该 prompt,事件流中跟随assistant回合(可带usage);
  3. 需要审批的操作触发control_request,外部confirmation_response生效后,通道出现control_response
  4. 会话正常结束时收到system/session_end

完整协议细节、POC 脚本与更多嵌入场景(IDE 扩展、浏览器 Chat 前端、CI 观察者、多 agent 编排、可观测性看板)参见 docs/users/features/dual-output.md。

【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-code

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/13 6:45:57

二维差分数组详解:从矩形批量更新到前缀和的高效算法

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/13 6:45:45

老旧安卓机也能跑30fps?AI美颜特效渲染优化实践拆解

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/13 6:41:30

Hyperswitch API 返回 429 时如何区分速率限制与 API 对象锁定

Hyperswitch API 返回 429 时如何区分速率限制与 API 对象锁定 【免费下载链接】hyperswitch Open source, composable payments platform | PCI compliant | SaaS and Self-host options | Enables connectivity to multiple payment, payout, fraud, vault and tokenization …

作者头像 李华
网站建设 2026/9/13 6:41:06

HTML基础语法入门:从标签结构到实战避坑完整指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华