Skip to content

fix(pipeline): 流式输出时保留发送期装饰(引用 / @) - #10491

Closed
td1336065617 wants to merge 6 commits into
AstrBotDevs:masterfrom
td1336065617:fix/streaming-result-decoration
Closed

td1336065617 wants to merge 6 commits into
AstrBotDevs:masterfrom
td1336065617:fix/streaming-result-decoration

Conversation

@td1336065617

@td1336065617 td1336065617 commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

问题

开启流式输出(provider_settings.streaming_response = true)后,引用回复与 @ 发送人全部失效:
回复不再引用任何人,也不再 @ 人;而插件直接 event.send() 发出的消息、以及内置命令(/reset 等)仍正常引用。

关掉流式后立即恢复,边界非常明确。

关联 issue:#10490

根因

result_decorate 在流式场景下根本走不到插入装饰的代码:

# astrbot/core/pipeline/result_decorate/stage.py(修复前)
130     result = event.get_result()
131     if result is None or not result.chain:
132         return                              # ← 流式结果在这里就返回了
133
134     if result.result_content_type == ResultContentType.STREAMING_RESULT:
135         return
...
444         if self.reply_with_quote:           # 永远不会执行到
445             result.chain.insert(0, Reply(id=event.message_obj.message_id))

关键点:流式结果的 result.chain 恒为空 —— 正文在 result.async_stream 里,
而 respond 阶段对 STREAMING_RESULT 是直接发送 async_stream(respond/stage.py:217-231),
完全不读 result.chain。

因此在流式路径上,第 131 行就已经返回,后面的 STREAMING_RESULT 判断与装饰插入都执行不到。

实测证据(result_decorate.process 入口加临时日志):

content_type=None                  chain_len=0    × 39
content_type=STREAMING_RESULT      chain_len=0    ×  1   ← 进来了,但 chain 为空
content_type=STREAMING_FINISH      chain_len=1    ×  1

与 #3501 / #6018 的区别

此前有两次尝试被关闭,本 PR 刻意避开它们被卡住的点:

本 PR 的做法:

  1. 共享阶段只记录决策:result_decorate 把「本次是否需要引用 / @」写入 event 的扩展字段
    (set_extra),不修改 result.chain、不改变任何函数签名;
  2. 由平台自己决定如何应用:send_streaming 的实现按需读取该扩展字段,在首个分片上应用。

这样共享阶段保持平台无关,新增能力对未改动的平台是零影响(它们只是忽略这个扩展字段)。

改动

astrbot/core/pipeline/result_decorate/stage.py

  • 新增 _stash_streaming_decorations():把 {"quote": bool, "mention": bool} 写入
    event.set_extra("_streaming_decorations", ...);mention 与非流式路径一致地排除私聊。
  • 把 STREAMING_RESULT / STREAMING_FINISH 的判断移到「chain 判空」之前(这是本次的核心修复)。

astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py

  • 新增 _apply_streaming_decorations():读取扩展字段并在首个分片插入 Reply / At,
    读取后立即清空,保证只应用一次(后续分片不会重复引用)。
  • 在 send_streaming 的 async for chain in generator 循环里调用。
  • 链中存在非 Plain/Image 组件时跳过,与 result_decorate 的非流式路径保持一致。

实测验证

本机部署(AstrBot 4.28.2,qq_official 群聊,开启流式 + 开启引用)实测日志:

[DecoProbe] enter   content_type=ResultContentType.STREAMING_RESULT chain_len=0
[DecoProbe] stashed {'quote': True, 'mention': False} ct=ResultContentType.STREAMING_RESULT
[DecoProbe] apply   deco={'quote': True, 'mention': False} chain_types=['Plain']
[DecoProbe] apply   deco=None                                        ← 后续分片,不重复

客户端表现:回复带着引用气泡,同时正文仍然是逐字流式输出。

测试

新增 tests/test_streaming_result_decoration.py(6 个用例):

  • 核心回归:STREAMING_RESULT 且 chain 为空时,result_decorate 仍必须记录装饰决策;
  • STREAMING_FINISH 同样记录;
  • 决策内容随 reply_with_quote / reply_with_mention 配置变化;
  • _apply_streaming_decorations:首个分片插入 Reply、决策被取走(只应用一次);
  • 无暂存决策时不改动消息链;
  • 链中含非 Plain/Image 组件时跳过。

说明:测试只断言「消息链被正确装饰」,不涉及各平台如何把 Reply 转成其平台参数
(例如 qq_official 的 message_reference 由 #10489 负责),因此本 PR 可独立合并与验证。

范围与限制

  • 本次只在 qq_official 的 send_streaming 中应用装饰。其它平台如需支持,
    用同样的方式读取 _streaming_decorations 即可,无需改动共享阶段。
  • mention 在 qq_official 上会拼成官方现行嵌入文本协议 <qqbot-at-user id="..." />;
    AtAll(qq == "all")对应 <qqbot-at-everyone />,官方限定「仅在文字子频道可用」,故跳过。
  • 协议渲染依赖平台权限,本 PR 不做保证;本次实测可确认的是引用回复在流式下恢复正常。

Summary by Sourcery

Restore reply and mention decorations for streaming responses without changing shared pipeline interfaces.

Bug Fixes:

  • Restore quote replies and sender mentions for streaming responses in the QQ Official platform.

Enhancements:

  • Preserve streaming decoration decisions in shared pipeline event metadata and apply them once to the first streaming chunk at the platform layer.

Tests:

  • Add regression coverage for streaming decoration decisions, one-time application, source-chain immutability, missing decisions, and unsupported message components.

result_decorate 在流式场景下无法修改 result.chain:respond 阶段直接使用result.async_stream,且流式结果的 chain 恒为空。而 STREAMING_RESULT 分支原本位于「chain 判空」之后,导致装饰决策无处安放 ——这正是开启流式后 reply_with_quote / reply_with_mention 失效的原因。

改为把「本次需要哪些发送期装饰」记录到事件扩展字段,由平台自身的 send_streaming 按需应用,从而不改动共享阶段接口、也不替平台决定发送细节。
读取 result_decorate 暂存的决策,在首个分片插入 Reply / At,取走后清空以保证只应用一次。链中含非 Plain/Image 组件时跳过,与非流式路径保持一致。

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 issue

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py" line_range="288" />
<code_context>
         try:
             async for chain in generator:
                 source = self.message_obj.raw_message
+                self._apply_streaming_decorations(chain)

                 if not isinstance(source, botpy.message.C2CMessage):
</code_context>
<issue_to_address>
**Stream repeats decorations**

When the upstream reuses a mutable `MessageChain` across chunks, `_apply_streaming_decorations` mutates the generator-owned chain, so retained `At` or `Reply` components are copied into later output by `_append_stream_delta`; the reply repeats the mention or quote.

Copy the chain before adding streaming decorations.

Also at `astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py:263-269`.
</issue_to_address>

Sourcery assessment

Needs a human reviewer. 1 finding to address first, and if the decoration state is applied incorrectly, the first streamed message could be sent with a wrong reply target or an unintended @ mention, and that already-delivered message cannot be undone by reverting the change. Future messages would return to the previous behavior after a revert, so the impact is bounded to messages sent while this code is active.

Blocking findings: astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py:288


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

Comment thread astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py Outdated
上游生成器可能跨分片复用同一个 MessageChain;此前直接在传入对象上插入 Reply/At,已插入的组件会被 _append_stream_delta 再次累积,导致重复引用。

改为 deepcopy 后插入并返回,同时调用处接收返回值;不满足装饰条件时原样返回。
@td1336065617

Copy link
Copy Markdown
Contributor Author

合理,已按建议改为「复制后再装饰」。

确认问题

_apply_streaming_decorations 直接修改了上游生成器拥有的 MessageChain。
虽然实测中装饰只被应用一次(决策取走后清空,见下),但修改不属于自己的对象本身就是错的:

  • 上游若跨分片复用同一个 chain,已插入的 Reply / At 会被 _append_stream_delta 的
    copy.deepcopy(comp) 分支再次累积进 send_buffer;
  • 这一点从 _append_stream_delta 专门为非 Plain 组件做深拷贝也能看出,上游确实预期 chain 可能被复用。

已修(2cd0cb826e)

-    def _apply_streaming_decorations(self, chain: MessageChain) -> None:
+    def _apply_streaming_decorations(self, chain: MessageChain) -> MessageChain:
         ...
         if not all(isinstance(c, (Plain, Image)) for c in chain.chain):
-            return
+            return chain
         try:
+            decorated = copy.deepcopy(chain)
+            if decorations.get("mention"):
+                decorated.chain.insert(0, At(qq=..., name=...))
+                ...
+            if decorations.get("quote"):
+                decorated.chain.insert(0, Reply(id=self.message_obj.message_id))
+            return decorated
+        except Exception:
+            logger.debug("apply streaming decorations failed", exc_info=True)
+            return chain

调用处同步改为接收返回值:

             async for chain in generator:
                 source = self.message_obj.raw_message
-                self._apply_streaming_decorations(chain)
+                chain = self._apply_streaming_decorations(chain)

即:不满足条件或异常时原样返回传入对象(零开销),满足条件时返回装饰后的副本。

关于「重复引用」的另一道防线

装饰决策是从 event 扩展字段取走后立即清空的,所以即便 chain 被复用,
第二次进入该函数时 decorations 已是 None,会直接原样返回 —— 这也是本次实测中
日志只出现一次 apply deco={...}、随后全是 deco=None 的原因。
不过这只是「行为上侥幸正确」,把对象所有权改对才是根本修复,感谢指出。

测试

新增两条用例(tests/test_streaming_result_decoration.py,现 7 passed):

  • test_apply_returns_decorated_copy —— 返回的副本带 Reply,原链长度不变;
  • test_apply_does_not_mutate_source_chain —— 断言 decorated is not chain、原链仍只有 1 个 Plain,
    并对同一个 chain 再调用一次,确认不再装饰。

本地复核:

ruff check .          → All checks passed!
ruff format --check . → already formatted
pytest tests/test_qqofficial_*.py tests/test_streaming_result_decoration.py
                      → 71 passed

与 AstrBotDevs#10489 同一类问题:_apply_streaming_decorations() 使用了 Reply,但 import 行没有它。该分支的 event 文件同样是从 master 重建的,master 的 import 行不含 Reply。

本地按 CI 方式在真实检出上复核:ruff check 全绿、ruff format 全绿、71 项测试通过。
@Soulter

Soulter commented Oct 11, 2026

Copy link
Copy Markdown
Member

感谢贡献。流式输出时跳过该过程是预期的。

@Soulter Soulter closed this Oct 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants