mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
fix(llm): avoid quadratic think-block stripping on unclosed tags (#5854)
* fix(llm): avoid quadratic think-block stripping on unclosed tags * test(llm): pin think tag parsing edge cases --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
f67b1f574e
commit
50d2d68556
@ -8,6 +8,12 @@ which takes a whole message and uses different list/mapping semantics.
|
||||
Regression coverage lives in `tests/test_utils_messages.py`,
|
||||
`tests/test_subagent_executor.py`, and `tests/test_task_continuity.py`.
|
||||
|
||||
`llm_text.py::strip_think_blocks` scans opening and closing tags forward so
|
||||
repeated unclosed `<think>` prefixes in model output do not rescan the same
|
||||
suffix. Keep its case-insensitive tag handling, optional whitespace before
|
||||
the closing `>`, and the `truncate_unclosed` behavior. Regression coverage
|
||||
lives in `tests/test_utils_llm_text.py`.
|
||||
|
||||
### Agent / Tool Assembly Off-Load
|
||||
|
||||
Tool and agent assembly re-enters `get_available_tools()` and may block on MCP discovery, so the four async assembly entry points — `run_agent`'s `agent_factory` call, `task_tool`, durable batch `_execute_item`, and `abuild_checkpoint_state_accessor` — dispatch through `deerflow.utils.assembly_io.run_assembly`, a dedicated ContextVar-preserving bounded executor (`DEER_FLOW_ASSEMBLY_WORKERS`, default 8) rather than the loop's default executor; a hung MCP server therefore parks an assembly worker instead of queueing unrelated default-executor work, and the pool logs a warning when pending assemblies exceed the worker count. `tests/blocking_io/test_tool_assembly_offloop.py` pins all four offloads plus the ContextVar propagation.
|
||||
|
||||
@ -4,10 +4,31 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
# Matches a complete <think>...</think> block (case-insensitive, spans newlines).
|
||||
_THINK_BLOCK_RE = re.compile(r"<think\b[^>]*>.*?</think\s*>", re.IGNORECASE | re.DOTALL)
|
||||
# Matches a dangling, unclosed <think> (model truncated at max_tokens mid-thought).
|
||||
_OPEN_THINK_RE = re.compile(r"<think\b[^>]*>", re.IGNORECASE)
|
||||
_THINK_OPEN_PREFIX_RE = re.compile(r"<think\b", re.IGNORECASE)
|
||||
_THINK_CLOSE_PREFIX_RE = re.compile(r"</think", re.IGNORECASE)
|
||||
|
||||
|
||||
def _find_think_open(text: str, start: int) -> tuple[int, int] | None:
|
||||
"""Find the next complete opening tag without retrying its suffix at each prefix."""
|
||||
match = _THINK_OPEN_PREFIX_RE.search(text, start)
|
||||
if match is None:
|
||||
return None
|
||||
end = text.find(">", match.end())
|
||||
if end < 0:
|
||||
return None
|
||||
return match.start(), end + 1
|
||||
|
||||
|
||||
def _find_think_close(text: str, start: int) -> tuple[int, int] | None:
|
||||
"""Find the first closing tag, allowing whitespace before its final ``>``."""
|
||||
while match := _THINK_CLOSE_PREFIX_RE.search(text, start):
|
||||
end = match.end()
|
||||
while end < len(text) and text[end].isspace():
|
||||
end += 1
|
||||
if end < len(text) and text[end] == ">":
|
||||
return match.start(), end + 1
|
||||
start = match.end()
|
||||
return None
|
||||
|
||||
|
||||
def strip_think_blocks(text: str, *, truncate_unclosed: bool = True) -> str:
|
||||
@ -22,12 +43,18 @@ def strip_think_blocks(text: str, *, truncate_unclosed: bool = True) -> str:
|
||||
draft that mentions the tag) pass ``truncate_unclosed=False`` so the tag is
|
||||
preserved instead of silently discarding the rest of the text.
|
||||
"""
|
||||
text = _THINK_BLOCK_RE.sub("", text)
|
||||
if truncate_unclosed:
|
||||
open_match = _OPEN_THINK_RE.search(text)
|
||||
if open_match:
|
||||
text = text[: open_match.start()]
|
||||
return text.strip()
|
||||
parts: list[str] = []
|
||||
start = 0
|
||||
while (opening := _find_think_open(text, start)) is not None:
|
||||
closing = _find_think_close(text, opening[1])
|
||||
if closing is None:
|
||||
if truncate_unclosed:
|
||||
return ("".join(parts) + text[start : opening[0]]).strip()
|
||||
break
|
||||
parts.append(text[start : opening[0]])
|
||||
start = closing[1]
|
||||
parts.append(text[start:])
|
||||
return "".join(parts).strip()
|
||||
|
||||
|
||||
def strip_markdown_code_fence(text: str) -> str:
|
||||
|
||||
@ -2,6 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from deerflow.utils.llm_text import (
|
||||
extract_response_text,
|
||||
strip_markdown_code_fence,
|
||||
@ -65,6 +69,30 @@ def test_strip_think_blocks_removes_complete_and_keeps_dangling_when_disabled()
|
||||
assert result == "keep<think>trunc"
|
||||
|
||||
|
||||
def test_strip_think_blocks_does_not_treat_junk_close_prefix_as_closing_tag() -> None:
|
||||
text = "a<think>x</thinky>b"
|
||||
assert strip_think_blocks(text) == "a"
|
||||
assert strip_think_blocks(text, truncate_unclosed=False) == text
|
||||
|
||||
|
||||
def test_strip_think_blocks_accepts_multiline_whitespace_before_close() -> None:
|
||||
assert strip_think_blocks("<THINK>x</THINK\n >ok") == "ok"
|
||||
|
||||
|
||||
def test_strip_think_blocks_consumes_multiple_opens_through_first_close() -> None:
|
||||
assert strip_think_blocks("<think>a<think>b</think>c") == "c"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tag", ["<think>", "<think"])
|
||||
@pytest.mark.parametrize("truncate_unclosed", [True, False])
|
||||
def test_strip_think_blocks_handles_many_unclosed_tags_in_bounded_time(tag: str, truncate_unclosed: bool) -> None:
|
||||
text = "answer" + tag * 12_000
|
||||
expected = "answer" if tag.endswith(">") and truncate_unclosed else text
|
||||
start = time.perf_counter()
|
||||
assert strip_think_blocks(text, truncate_unclosed=truncate_unclosed) == expected
|
||||
assert time.perf_counter() - start < 1.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# strip_markdown_code_fence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user