feat(agent): control reasoning visibility
This commit is contained in:
@@ -29,6 +29,7 @@ from app.services.agent_debug_service import AgentDebugService
|
||||
from app.services.feishu_service import FeishuKnowledgeService
|
||||
from app.services.knowledge_service import KnowledgeScope
|
||||
from app.services.model_service import ModelClientService
|
||||
from app.services.reasoning_policy_service import ReasoningPolicyService
|
||||
from app.services.secret_service import MASKED_SECRET, SENSITIVE_CONFIG_KEYS, SecretService
|
||||
|
||||
router = APIRouter()
|
||||
@@ -205,7 +206,7 @@ def get_agent_runtime_config(
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
model = _enabled_model(db)
|
||||
return api_success(_agent_runtime_config_dict(model))
|
||||
return api_success(_agent_runtime_config_dict(model, ReasoningPolicyService.is_visible(db)))
|
||||
|
||||
|
||||
@router.put("/agent/runtime-config")
|
||||
@@ -228,6 +229,7 @@ def save_agent_runtime_config(
|
||||
model.max_token = payload.maxToken
|
||||
model.stream_enabled = payload.streamEnabled
|
||||
db.add(model)
|
||||
ReasoningPolicyService.set_visible(db, payload.reasoningVisible == 1, current_admin.id)
|
||||
OperationLogService.write(
|
||||
db,
|
||||
admin_id=current_admin.id,
|
||||
@@ -237,7 +239,7 @@ def save_agent_runtime_config(
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(model)
|
||||
return api_success(_agent_runtime_config_dict(model))
|
||||
return api_success(_agent_runtime_config_dict(model, ReasoningPolicyService.is_visible(db)))
|
||||
|
||||
|
||||
@router.get("/model/list")
|
||||
@@ -441,7 +443,7 @@ def _enabled_model(db: Session) -> ModelConfig | None:
|
||||
)
|
||||
|
||||
|
||||
def _agent_runtime_config_dict(model: ModelConfig | None) -> dict:
|
||||
def _agent_runtime_config_dict(model: ModelConfig | None, reasoning_visible: bool = False) -> dict:
|
||||
return {
|
||||
"modelId": model.id if model is not None else None,
|
||||
"modelName": (model.display_name or model.model_name) if model is not None else None,
|
||||
@@ -452,6 +454,7 @@ def _agent_runtime_config_dict(model: ModelConfig | None) -> dict:
|
||||
"frequencyPenalty": float(model.frequency_penalty) if model is not None and model.frequency_penalty is not None else None,
|
||||
"maxToken": model.max_token if model is not None and model.max_token is not None else 8192,
|
||||
"streamEnabled": model.stream_enabled if model is not None else 1,
|
||||
"reasoningVisible": 1 if reasoning_visible else 0,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ from app.services.chat_queue_runtime import (
|
||||
)
|
||||
from app.services.chat_queue_service import load_chat_queue_config
|
||||
from app.services.chat_stream_service import ChatStreamService
|
||||
from app.services.reasoning_policy_service import ReasoningPolicyService
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -54,7 +55,14 @@ def history(
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> dict:
|
||||
messages = ChatService.get_history(db, current_user, sessionId)
|
||||
return api_success([ChatMessageRead.model_validate(message).model_dump(mode="json") for message in messages])
|
||||
reasoning_visible = ReasoningPolicyService.is_visible(db)
|
||||
result = []
|
||||
for message in messages:
|
||||
item = ChatMessageRead.model_validate(message).model_dump(mode="json")
|
||||
if message.role == "assistant" and not reasoning_visible:
|
||||
item["content"] = ReasoningPolicyService.strip_reasoning(item["content"])
|
||||
result.append(item)
|
||||
return api_success(result)
|
||||
|
||||
|
||||
@router.put("/session/title")
|
||||
@@ -152,14 +160,20 @@ async def _chat_stream(payload: ChatCompletionRequest, db: Session, current_user
|
||||
return
|
||||
|
||||
try:
|
||||
reasoning_visible = ReasoningPolicyService.is_visible(db)
|
||||
yield _sse_event(
|
||||
"generating",
|
||||
message="已进入生成队列,正在生成回答。",
|
||||
message="思考中",
|
||||
activeCount=queue_request.active_count,
|
||||
waitingCount=queue_request.waiting_count,
|
||||
reasoningVisible=reasoning_visible,
|
||||
)
|
||||
async for chunk in ChatStreamService.stream_answer_async(db, current_user, payload.sessionId, payload.message):
|
||||
yield _sse_event("content", content=chunk)
|
||||
chunks = ChatStreamService.stream_answer_async(db, current_user, payload.sessionId, payload.message)
|
||||
async for segment in ReasoningPolicyService.iter_segments(chunks):
|
||||
if segment.kind == "content":
|
||||
yield _sse_event("content", content=segment.content)
|
||||
elif reasoning_visible:
|
||||
yield _sse_event("reasoning", content=segment.content)
|
||||
except HTTPException as exc:
|
||||
yield _sse_event("error", message=str(exc.detail))
|
||||
except asyncio.CancelledError:
|
||||
|
||||
@@ -99,6 +99,7 @@ class AgentRuntimeConfigSaveRequest(BaseModel):
|
||||
frequencyPenalty: float | None = Field(default=None, ge=-2, le=2)
|
||||
maxToken: int = Field(default=8192, ge=256, le=100000)
|
||||
streamEnabled: int = Field(default=1, ge=0, le=1)
|
||||
reasoningVisible: int = Field(default=0, ge=0, le=1)
|
||||
|
||||
|
||||
class ModelSaveRequest(BaseModel):
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.schemas.admin import AgentDebugRequest
|
||||
from app.services.admin_service import OperationLogService
|
||||
from app.services.knowledge_agent_service import KnowledgeAgentService
|
||||
from app.services.model_stream_service import ModelStreamService
|
||||
from app.services.reasoning_policy_service import ReasoningPolicyService
|
||||
from app.services.rag_service import PromptService, RagResult
|
||||
|
||||
|
||||
@@ -61,16 +62,24 @@ class AgentDebugService:
|
||||
yield {"type": "error", "message": "模型不存在"}
|
||||
return
|
||||
try:
|
||||
yield {"type": "status", "stage": "retrieving", "message": "思考中"}
|
||||
reasoning_visible = ReasoningPolicyService.is_visible(db)
|
||||
yield {
|
||||
"type": "status",
|
||||
"stage": "retrieving",
|
||||
"message": "思考中",
|
||||
"reasoningVisible": reasoning_visible,
|
||||
}
|
||||
rag_result = await cls.build_result(db, payload)
|
||||
model_response = ModelStreamService.debug_stream_async(
|
||||
model,
|
||||
rag_result,
|
||||
cls.overrides(payload),
|
||||
)
|
||||
async for chunk in model_response.chunks:
|
||||
if chunk:
|
||||
yield {"type": "content", "content": chunk}
|
||||
async for segment in ReasoningPolicyService.iter_segments(model_response.chunks):
|
||||
if segment.kind == "content":
|
||||
yield {"type": "content", "content": segment.content}
|
||||
elif reasoning_visible:
|
||||
yield {"type": "reasoning", "content": segment.content}
|
||||
OperationLogService.write(
|
||||
db,
|
||||
admin_id=current_admin.id,
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.ai_config import SystemConfig
|
||||
|
||||
|
||||
REASONING_VISIBILITY_KEY = "show_model_reasoning"
|
||||
_OPEN_TAG = re.compile(r"^<think(?:\s[^>]*)?>", re.IGNORECASE)
|
||||
_CLOSE_TAG = re.compile(r"^</think\s*>", re.IGNORECASE)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReasoningSegment:
|
||||
kind: Literal["reasoning", "content"]
|
||||
content: str
|
||||
|
||||
|
||||
class ReasoningStreamParser:
|
||||
"""Incrementally separates model reasoning tags from answer content."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._buffer = ""
|
||||
self._depth = 0
|
||||
|
||||
def feed(self, chunk: str) -> list[ReasoningSegment]:
|
||||
if not chunk:
|
||||
return []
|
||||
self._buffer += chunk
|
||||
return list(self._drain(final=False))
|
||||
|
||||
def finish(self) -> list[ReasoningSegment]:
|
||||
return list(self._drain(final=True))
|
||||
|
||||
def _drain(self, *, final: bool) -> Iterator[ReasoningSegment]:
|
||||
while self._buffer:
|
||||
tag_start = self._buffer.find("<")
|
||||
if tag_start < 0:
|
||||
yield self._segment(self._buffer)
|
||||
self._buffer = ""
|
||||
return
|
||||
if tag_start > 0:
|
||||
yield self._segment(self._buffer[:tag_start])
|
||||
self._buffer = self._buffer[tag_start:]
|
||||
continue
|
||||
|
||||
open_tag = _OPEN_TAG.match(self._buffer)
|
||||
if open_tag:
|
||||
self._depth += 1
|
||||
self._buffer = self._buffer[open_tag.end():]
|
||||
continue
|
||||
close_tag = _CLOSE_TAG.match(self._buffer)
|
||||
if close_tag:
|
||||
self._depth = max(0, self._depth - 1)
|
||||
self._buffer = self._buffer[close_tag.end():]
|
||||
continue
|
||||
|
||||
lower = self._buffer.lower()
|
||||
incomplete_tag = (
|
||||
"<think".startswith(lower)
|
||||
or "</think>".startswith(lower)
|
||||
or (lower.startswith("<think") and ">" not in lower)
|
||||
or (lower.startswith("</think") and ">" not in lower)
|
||||
)
|
||||
if incomplete_tag and not final:
|
||||
return
|
||||
if incomplete_tag and final:
|
||||
self._buffer = ""
|
||||
return
|
||||
|
||||
yield self._segment("<")
|
||||
self._buffer = self._buffer[1:]
|
||||
|
||||
def _segment(self, content: str) -> ReasoningSegment:
|
||||
return ReasoningSegment("reasoning" if self._depth else "content", content)
|
||||
|
||||
|
||||
class ReasoningPolicyService:
|
||||
@staticmethod
|
||||
def is_visible(db: Session) -> bool:
|
||||
config = db.scalar(select(SystemConfig).where(SystemConfig.config_key == REASONING_VISIBILITY_KEY))
|
||||
if config is None:
|
||||
return False
|
||||
return config.config_value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
@staticmethod
|
||||
def set_visible(db: Session, visible: bool, admin_id: int | None) -> SystemConfig:
|
||||
config = db.scalar(select(SystemConfig).where(SystemConfig.config_key == REASONING_VISIBILITY_KEY))
|
||||
if config is None:
|
||||
config = SystemConfig(config_key=REASONING_VISIBILITY_KEY, config_value="false")
|
||||
config.config_value = "true" if visible else "false"
|
||||
config.description = "是否向用户端及后台预览展示模型思考过程"
|
||||
config.updated_by = admin_id
|
||||
db.add(config)
|
||||
return config
|
||||
|
||||
@staticmethod
|
||||
def split_text(text: str) -> tuple[str, str]:
|
||||
parser = ReasoningStreamParser()
|
||||
segments = [*parser.feed(text), *parser.finish()]
|
||||
answer = "".join(item.content for item in segments if item.kind == "content")
|
||||
reasoning = "".join(item.content for item in segments if item.kind == "reasoning")
|
||||
return answer, reasoning
|
||||
|
||||
@classmethod
|
||||
def strip_reasoning(cls, text: str) -> str:
|
||||
return cls.split_text(text)[0]
|
||||
|
||||
@staticmethod
|
||||
async def iter_segments(chunks: AsyncIterator[str]) -> AsyncIterator[ReasoningSegment]:
|
||||
parser = ReasoningStreamParser()
|
||||
async for chunk in chunks:
|
||||
for segment in parser.feed(chunk):
|
||||
if segment.content:
|
||||
yield segment
|
||||
for segment in parser.finish():
|
||||
if segment.content:
|
||||
yield segment
|
||||
Reference in New Issue
Block a user