fix: stream agentic reasoning and answers correctly (#17849)

This commit is contained in:
buua436
2026-08-05 15:59:19 +08:00
committed by GitHub
parent affad91b08
commit cd6e73bcb5
3 changed files with 155 additions and 69 deletions

View File

@@ -32,6 +32,7 @@ the fast non-tool-calling path.
import logging
import re
from collections.abc import Callable
from typing import Any, List
import json_repair
@@ -42,7 +43,7 @@ from api.db.services.llm_service import LLMBundle
from common import settings
from common.misc_utils import thread_pool_exec
from common.token_utils import num_tokens_from_string
from rag.advanced_rag.agentic_rag_graph import _strip_think_stream
from rag.advanced_rag.agentic_rag_graph import _split_think_stream
from rag.app.tag import label_question
from rag.llm.tool_decorator import tool
from rag.prompts.generator import (
@@ -79,6 +80,7 @@ class RAGTools:
meta_data_filter: dict | None = None,
doc_scope: List[str] | None = None,
user_defined_prompts: dict | None = None,
empty_response: str = "",
do_refer: bool | None = True,
thinking_mode: str = "medium",
):
@@ -110,8 +112,14 @@ class RAGTools:
self.meta_data_filter = meta_data_filter
self.doc_scope = list(dict.fromkeys(doc_scope)) if doc_scope is not None else None
self.user_defined_prompts = user_defined_prompts or {}
self.kbinfos = {"chunks": [], "doc_aggs": []}
self.empty_response = empty_response
self.do_refer = do_refer
# Optional sink used by the outer agent stream to preserve the final
# answer deltas produced by the inner research graph. The tool API
# still returns the complete string to the caller, but the stream
# endpoint can forward the original deltas instead of that aggregate.
self.answer_sink: Callable[[str, bool], None] | None = None
self.tool_started_sink: Callable[[], None] | None = None
# Citation pool shared with the final-answer node: the graph publishes
# the chunks it actually used here (in the SAME order the answer's
# ``[ID:n]`` markers index), so the caller can resolve references.
@@ -562,11 +570,15 @@ class RAGTools:
"""
from rag.advanced_rag.agentic_rag_graph import run_agentic_rag
if self.tool_started_sink is not None:
self.tool_started_sink()
messages = [{"role": "user", "content": question}] if question else []
final = ""
async for delta in _strip_think_stream(run_agentic_rag(self, messages)):
if isinstance(delta, str):
async for kind, delta in _split_think_stream(run_agentic_rag(self, messages)):
if kind == "answer":
final += delta
if self.answer_sink is not None:
self.answer_sink(delta, kind == "think")
for p, r in [(r"\(\**(ID:\d)\**\)", "[\1]")]:
final = re.sub(p, r, final)
return final

View File

@@ -33,6 +33,7 @@ from __future__ import annotations
import asyncio
import json
import re
import logging
from typing import Any, TypedDict
@@ -85,45 +86,65 @@ def _partial_tag_tail(s: str, tag: str) -> int:
return 0
async def _strip_think_stream(stream):
"""Strip <think>...</think> spans from a token stream."""
async def _split_think_stream(stream):
"""Split model deltas into ``think`` and ``answer`` text.
Besides ordinary ``<think>...</think>`` streams, some providers emit the
opening tag only on the first reasoning delta and append ``</think>`` to
every subsequent delta. An unmatched closing tag therefore still marks
the text before it as reasoning.
"""
buf = ""
in_think = False
async for token in stream:
if not isinstance(token, str):
yield token
_LOG.warning("Ignoring non-string agentic RAG stream item of type %s", type(token).__name__)
continue
buf += token
out = []
while buf:
if not in_think:
idx = buf.find(_THINK_OPEN)
if idx == -1:
hold = _partial_tag_tail(buf, _THINK_OPEN)
if hold:
out.append(buf[: len(buf) - hold])
buf = buf[len(buf) - hold :]
else:
out.append(buf)
buf = ""
break
out.append(buf[:idx])
buf = buf[idx + len(_THINK_OPEN) :]
in_think = True
else:
idx = buf.find(_THINK_CLOSE)
if idx != -1:
buf = buf[idx + len(_THINK_CLOSE) :]
if in_think:
close_idx = buf.find(_THINK_CLOSE)
if close_idx >= 0:
if close_idx:
yield "think", buf[:close_idx]
buf = buf[close_idx + len(_THINK_CLOSE) :]
in_think = False
continue
hold = _partial_tag_tail(buf, _THINK_CLOSE)
safe = buf[: len(buf) - hold] if hold else buf
if safe:
yield "think", safe
buf = buf[len(buf) - hold :] if hold else ""
break
piece = "".join(out)
if piece:
yield piece
if buf and not in_think:
yield buf
open_idx = buf.find(_THINK_OPEN)
close_idx = buf.find(_THINK_CLOSE)
if close_idx >= 0 and (open_idx < 0 or close_idx < open_idx):
if close_idx:
yield "think", buf[:close_idx]
buf = buf[close_idx + len(_THINK_CLOSE) :]
continue
if open_idx >= 0:
if open_idx:
yield "answer", buf[:open_idx]
buf = buf[open_idx + len(_THINK_OPEN) :]
in_think = True
continue
hold = max(_partial_tag_tail(buf, _THINK_OPEN), _partial_tag_tail(buf, _THINK_CLOSE))
safe = buf[: len(buf) - hold] if hold else buf
if safe:
yield "answer", safe
buf = buf[len(buf) - hold :] if hold else ""
break
if buf:
yield ("think" if in_think else "answer"), re.sub(r"</?think>", "", buf)
# ── Graph construction ──
@@ -235,22 +256,19 @@ def build_agentic_graph(tools, token_queue: asyncio.Queue, gen_conf: dict | None
tools.kbinfos = kbinfos
# Abstain
if abstain:
msg = "I cannot answer this question based on the available information."
token_queue.put_nowait(msg)
return {"final_answer": msg}
# Empty result
if empty_result or not kbinfos["chunks"]:
msg = "I don't have enough information based on the available sources."
token_queue.put_nowait(msg)
return {"final_answer": msg}
no_evidence = abstain or empty_result or not kbinfos["chunks"]
if no_evidence and tools.empty_response:
_LOG.info("[Composing the answer] No supporting evidence was found; returning the configured empty response without calling the answer model.")
token_queue.put_nowait(tools.empty_response)
return {"final_answer": tools.empty_response}
# Build evidence
evidence = kb_prompt(kbinfos, tools.chat_mdl.max_length)
parts = [f"Question:\n{question}\n"]
if no_evidence:
parts.append("No supporting evidence was retrieved. State clearly that the available sources are insufficient, and do not answer from general knowledge.\n")
# Include pre_summary from agent results if available
pre_summary = kbinfos.get("pre_summary")
if pre_summary: