diff --git a/app/agent/orchestrator.py b/app/agent/orchestrator.py index 1c70d6772..4bb0a6437 100644 --- a/app/agent/orchestrator.py +++ b/app/agent/orchestrator.py @@ -224,8 +224,14 @@ class _ThinkTagStripper: """ 流式剥离 ... 标签的辅助类。 维护内部缓冲区,处理标签跨 token 边界被截断的情况。 + + 部分模型会将思考正文放到 reasoning_content 字段,只把孤立的结束标签 + 放进普通文本流;这类结束标签同样不能交给用户界面展示。 """ + _THINK_START_TAG = "" + _THINK_END_TAG = "" + def __init__(self): self.buffer = "" self.in_think_tag = False @@ -235,6 +241,14 @@ class _ThinkTagStripper: self.buffer = "" self.in_think_tag = False + @staticmethod + def _find_partial_tag_length(text: str, tag: str) -> int: + """返回文本末尾匹配的标签前缀长度,完整标签不计入。""" + for length in range(len(tag) - 1, 0, -1): + if text.endswith(tag[:length]): + return length + return 0 + def process(self, text: str, on_output: Callable[[str], None]): """ 将新文本送入处理,剥离 标签后通过 on_output 回调输出。 @@ -246,43 +260,45 @@ class _ThinkTagStripper: emitted = False while self.buffer: if not self.in_think_tag: - start_idx = self.buffer.find("") - if start_idx != -1: + start_idx = self.buffer.find(self._THINK_START_TAG) + end_idx = self.buffer.find(self._THINK_END_TAG) + if end_idx != -1 and (start_idx == -1 or end_idx < start_idx): + if end_idx > 0: + on_output(self.buffer[:end_idx]) + emitted = True + self.buffer = self.buffer[end_idx + len(self._THINK_END_TAG) :] + elif start_idx != -1: if start_idx > 0: on_output(self.buffer[:start_idx]) emitted = True self.in_think_tag = True - self.buffer = self.buffer[start_idx + 7 :] + self.buffer = self.buffer[start_idx + len(self._THINK_START_TAG) :] else: - # 检查是否以 的不完整前缀结尾 - partial_match = False - for i in range(6, 0, -1): - if self.buffer.endswith(""[:i]): - if len(self.buffer) > i: - on_output(self.buffer[:-i]) - emitted = True - self.buffer = self.buffer[-i:] - partial_match = True - break - if partial_match: + # 同时保留开始和结束标签的半截,避免标签被拆到不同 token 后泄漏。 + partial_length = max( + self._find_partial_tag_length(self.buffer, self._THINK_START_TAG), + self._find_partial_tag_length(self.buffer, self._THINK_END_TAG), + ) + if partial_length: + if len(self.buffer) > partial_length: + on_output(self.buffer[:-partial_length]) + emitted = True + self.buffer = self.buffer[-partial_length:] break on_output(self.buffer) emitted = True self.buffer = "" else: - end_idx = self.buffer.find("") + end_idx = self.buffer.find(self._THINK_END_TAG) if end_idx != -1: self.in_think_tag = False - self.buffer = self.buffer[end_idx + 8 :] + self.buffer = self.buffer[end_idx + len(self._THINK_END_TAG) :] else: # 检查是否以 的不完整前缀结尾 - partial_match = False - for i in range(7, 0, -1): - if self.buffer.endswith(""[:i]): - self.buffer = self.buffer[-i:] - partial_match = True - break - if not partial_match: + partial_length = self._find_partial_tag_length(self.buffer, self._THINK_END_TAG) + if partial_length: + self.buffer = self.buffer[-partial_length:] + else: self.buffer = "" break return emitted @@ -290,6 +306,10 @@ class _ThinkTagStripper: def flush(self, on_output: Callable[[str], None]): """流式结束时,输出缓冲区中剩余的非思考内容""" if self.buffer and not self.in_think_tag: + # 流结束时孤立的半截结束标签也属于协议标记,不应泄漏到回复正文。 + if self._find_partial_tag_length(self.buffer, self._THINK_END_TAG) >= 2: + self.buffer = "" + return on_output(self.buffer) self.buffer = "" diff --git a/tests/test_agent_tool_streaming.py b/tests/test_agent_tool_streaming.py index e5fe05640..26e2cae8c 100644 --- a/tests/test_agent_tool_streaming.py +++ b/tests/test_agent_tool_streaming.py @@ -45,6 +45,29 @@ def test_think_tag_stripper_hides_split_think_tag_content(): assert outputs == ["回答前", "回答后"] +def test_think_tag_stripper_ignores_orphan_closing_tag(): + """模型单独输出 think 结束标签时不应把协议标记展示给用户。""" + stripper = _ThinkTagStripper() + outputs = [] + + emitted = stripper.process("", outputs.append) + stripper.process("最终回答", outputs.append) + + assert emitted is False + assert outputs == ["最终回答"] + + +def test_think_tag_stripper_hides_split_orphan_closing_tag(): + """孤立结束标签跨 token 到达时仍应被过滤并保留前后正文。""" + stripper = _ThinkTagStripper() + outputs = [] + + stripper.process("回答前回答后", outputs.append) + + assert outputs == ["回答前", "回答后"] + + class DummyTool(MoviePilotTool): """用于流式输出测试的固定结果工具。"""