修复大模型思考输出问题
This commit is contained in:
@@ -13,7 +13,7 @@ from typing import Optional
|
|||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from config import Config
|
from config import Config
|
||||||
from bot.reply_parser import extract_final_reply
|
from bot.reply_parser import parse_reply
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -72,6 +72,46 @@ def clear_session(user_id: str):
|
|||||||
_user_sessions.pop(user_id, None)
|
_user_sessions.pop(user_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 显示格式化
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def _format_turn_for_display(text: str, use_emoji: bool = True) -> str:
|
||||||
|
"""
|
||||||
|
将一轮的原始文本( thinking... response...)格式化为微信展示文本。
|
||||||
|
use_emoji=True 用 💭 前缀(流式阶段,企微会渲染为可折叠的"已深度思考")
|
||||||
|
use_emoji=False 用纯文本标题(最终回复,避免触发"思考中"指示器)
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
prefix = "💭 思考过程:" if use_emoji else "【思考过程】"
|
||||||
|
|
||||||
|
# 查找 response 分隔标记
|
||||||
|
parts = text.split('\n response\n', 1)
|
||||||
|
if len(parts) == 2:
|
||||||
|
think_raw, body_raw = parts
|
||||||
|
think_clean = think_raw.strip()
|
||||||
|
if think_clean.startswith(' thinking'):
|
||||||
|
think_clean = think_clean[len(' thinking'):].lstrip('\n')
|
||||||
|
think_clean = think_clean.strip()
|
||||||
|
body_clean = body_raw.strip()
|
||||||
|
|
||||||
|
result = ""
|
||||||
|
if think_clean:
|
||||||
|
result = f"{prefix}\n{think_clean}\n\n"
|
||||||
|
if body_clean:
|
||||||
|
result += body_clean
|
||||||
|
return result
|
||||||
|
else:
|
||||||
|
# 还没有 response,全是思考内容
|
||||||
|
think_clean = text.strip()
|
||||||
|
if think_clean.startswith(' thinking'):
|
||||||
|
think_clean = think_clean[len(' thinking'):].lstrip('\n')
|
||||||
|
think_clean = think_clean.strip()
|
||||||
|
return f"{prefix}\n{think_clean}" if think_clean else ""
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Agent 调用
|
# Agent 调用
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -118,20 +158,44 @@ async def call_agent_stream(
|
|||||||
return await call_agent_sync(user_id, message)
|
return await call_agent_sync(user_id, message)
|
||||||
|
|
||||||
stream_id = generate_req_id("stream")
|
stream_id = generate_req_id("stream")
|
||||||
full_text = ""
|
full_text = "" # 累积非 STOP 的增量文本
|
||||||
|
turn_start = 0 # 当前轮在 full_text 中的起始位置
|
||||||
|
stop_texts: list[str] = [] # 每轮 STOP 时的完整文本快照
|
||||||
|
event_count = 0
|
||||||
|
last_sent_display = "" # 上次已发送到微信的展示文本
|
||||||
has_sent_final = False
|
has_sent_final = False
|
||||||
has_sent_thinking = False
|
first_event_sent = False # 是否已发送过流式更新(用于5秒超时保护)
|
||||||
|
|
||||||
async for line in response.aiter_lines():
|
async for line in response.aiter_lines():
|
||||||
if not line.startswith("data: "):
|
if not line.startswith("data: "):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
data_str = line[6:]
|
data_str = line[6:]
|
||||||
|
event_count += 1
|
||||||
try:
|
try:
|
||||||
data = json.loads(data_str)
|
data = json.loads(data_str)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
|
logger.warning("SSE #%d JSON 解析失败: %s", event_count, data_str[:200])
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
finish_reason = data.get("finishReason", "")
|
||||||
|
content = data.get("content", {})
|
||||||
|
parts = content.get("parts", [])
|
||||||
|
|
||||||
|
# 日志:每个 SSE 事件的摘要
|
||||||
|
part_summary = []
|
||||||
|
for p in parts:
|
||||||
|
t = p.get("text", "")
|
||||||
|
part_summary.append(
|
||||||
|
f"text_len={len(t)}, thought={p.get('thought', False)}, "
|
||||||
|
f"preview={t[:50]!r}"
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"SSE #%d | finish=%s | parts=%d | %s",
|
||||||
|
event_count, finish_reason, len(parts),
|
||||||
|
" | ".join(part_summary) if part_summary else "(无parts)",
|
||||||
|
)
|
||||||
|
|
||||||
# 错误处理
|
# 错误处理
|
||||||
if "error" in data:
|
if "error" in data:
|
||||||
logger.error("Agent SSE 错误: %s", data["error"])
|
logger.error("Agent SSE 错误: %s", data["error"])
|
||||||
@@ -139,51 +203,82 @@ async def call_agent_stream(
|
|||||||
clear_session(user_id)
|
clear_session(user_id)
|
||||||
return "抱歉,处理消息时出现错误,请稍后重试。"
|
return "抱歉,处理消息时出现错误,请稍后重试。"
|
||||||
|
|
||||||
# 收集所有文本(含思考过程)
|
is_final = finish_reason == "STOP"
|
||||||
parts = data.get("content", {}).get("parts", [])
|
|
||||||
for part in parts:
|
|
||||||
text = part.get("text", "")
|
|
||||||
if not text:
|
|
||||||
continue
|
|
||||||
if part.get("thought"):
|
|
||||||
continue
|
|
||||||
full_text += text
|
|
||||||
|
|
||||||
# 只发送一次"正在思考"状态
|
if is_final:
|
||||||
if full_text and not has_sent_thinking and not has_sent_final:
|
# STOP 事件:ADK 会重复发送完整文本,不追加到 full_text
|
||||||
try:
|
# 保存当前轮的文本快照
|
||||||
await ws_client.reply_stream(
|
turn_text = full_text[turn_start:]
|
||||||
frame, stream_id, "💭 正在思考...", False
|
if turn_text:
|
||||||
|
stop_texts.append(turn_text)
|
||||||
|
logger.info(
|
||||||
|
">>> STOP #%d,当前轮文本长度: %d,前200字: %s",
|
||||||
|
len(stop_texts), len(turn_text), turn_text[:200],
|
||||||
)
|
)
|
||||||
has_sent_thinking = True
|
turn_start = len(full_text)
|
||||||
except Exception:
|
last_sent_display = "" # 新轮次重置展示缓存
|
||||||
pass
|
else:
|
||||||
|
# 非 STOP:增量文本
|
||||||
|
for part in parts:
|
||||||
|
text = part.get("text", "")
|
||||||
|
if text:
|
||||||
|
full_text += text
|
||||||
|
|
||||||
# 最终完成时提取正式回复并发送
|
# 流式更新微信消息
|
||||||
is_final = data.get("finishReason") == "STOP"
|
turn_text = full_text[turn_start:]
|
||||||
if is_final and full_text and not has_sent_final:
|
display = _format_turn_for_display(turn_text)
|
||||||
clean_reply = extract_final_reply(full_text)
|
if display and display != last_sent_display:
|
||||||
logger.info("Agent 回复完成,长度: %d", len(clean_reply))
|
try:
|
||||||
try:
|
await ws_client.reply_stream(
|
||||||
await ws_client.reply_stream(
|
frame, stream_id, display, False
|
||||||
frame, stream_id, clean_reply, True
|
)
|
||||||
)
|
last_sent_display = display
|
||||||
has_sent_final = True
|
first_event_sent = True
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.warning("推送最终回复失败: %s", e)
|
pass
|
||||||
|
|
||||||
# 兜底:如果流结束但未发送最终回复
|
# === SSE 流结束,发送最终回复 ===
|
||||||
if full_text and not has_sent_final:
|
logger.info(
|
||||||
clean_reply = extract_final_reply(full_text)
|
"=== SSE 流结束 === %d 个事件, %d 个 STOP, full_text 总长: %d",
|
||||||
logger.info("Agent SSE 兜底发送,长度: %d", len(clean_reply))
|
event_count, len(stop_texts), len(full_text),
|
||||||
|
)
|
||||||
|
for idx, st in enumerate(stop_texts):
|
||||||
|
tc, bc = parse_reply(st)
|
||||||
|
logger.info(
|
||||||
|
"STOP[%d] 解析: 思考=%d, 正文=%d, 正文前100字: %s",
|
||||||
|
idx, len(tc), len(bc), bc[:100] if bc else "(空)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 从最后一个有正文的 STOP 构建最终内容
|
||||||
|
final_body = ""
|
||||||
|
for stop_text in reversed(stop_texts):
|
||||||
|
_, body_content = parse_reply(stop_text)
|
||||||
|
if body_content:
|
||||||
|
final_body = body_content
|
||||||
|
break
|
||||||
|
|
||||||
|
# 兜底
|
||||||
|
if not final_body and stop_texts:
|
||||||
|
_, final_body = parse_reply(stop_texts[-1])
|
||||||
|
if not final_body and full_text:
|
||||||
|
_, final_body = parse_reply(full_text[turn_start:])
|
||||||
|
if not final_body and full_text:
|
||||||
|
final_body = full_text[turn_start:]
|
||||||
|
|
||||||
|
if final_body:
|
||||||
try:
|
try:
|
||||||
|
# 用纯正文 + finish=True 正常关闭流
|
||||||
|
# 思考过程在流式阶段已通过 💭 展示,finish=True 替换为正文
|
||||||
await ws_client.reply_stream(
|
await ws_client.reply_stream(
|
||||||
frame, stream_id, clean_reply, True
|
frame, stream_id, final_body, True
|
||||||
)
|
)
|
||||||
|
has_sent_final = True
|
||||||
|
logger.info(">>> 最终回复发送成功,正文长度: %d", len(final_body))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("推送兜底回复失败: %s", e)
|
logger.warning("推送最终回复失败: %s", e)
|
||||||
|
|
||||||
return extract_final_reply(full_text) if full_text else ""
|
_, body = parse_reply(full_text[turn_start:] if turn_start else full_text)
|
||||||
|
return body if body else ""
|
||||||
|
|
||||||
except httpx.ConnectError:
|
except httpx.ConnectError:
|
||||||
logger.error("无法连接到 Agent 服务: %s", Config.AGENT_BASE_URL)
|
logger.error("无法连接到 Agent 服务: %s", Config.AGENT_BASE_URL)
|
||||||
|
|||||||
@@ -9,8 +9,6 @@
|
|||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from aibot import generate_req_id
|
|
||||||
|
|
||||||
from bot.agent_client import call_agent_stream
|
from bot.agent_client import call_agent_stream
|
||||||
from bot.message_parser import extract_metadata, extract_text_content
|
from bot.message_parser import extract_metadata, extract_text_content
|
||||||
|
|
||||||
@@ -33,11 +31,8 @@ async def handle_message(ws_client, frame: dict, msg_type: str):
|
|||||||
|
|
||||||
# ---- 调用 Agent ----
|
# ---- 调用 Agent ----
|
||||||
reply = await call_agent_stream(ws_client, frame, user_id, content)
|
reply = await call_agent_stream(ws_client, frame, user_id, content)
|
||||||
|
|
||||||
# ---- 兜底:未收到回复 ----
|
|
||||||
if not reply:
|
if not reply:
|
||||||
stream_id = generate_req_id("stream")
|
logger.warning("Agent 未返回有效回复,用户: %s", user_id)
|
||||||
await ws_client.reply_stream(frame, stream_id, "(未收到回复)", True)
|
|
||||||
|
|
||||||
|
|
||||||
async def _resolve_content(
|
async def _resolve_content(
|
||||||
|
|||||||
@@ -1,55 +1,108 @@
|
|||||||
"""
|
"""
|
||||||
回复文本解析模块
|
回复文本解析模块
|
||||||
负责处理 Agent 返回的原始文本,提取最终正式回复。
|
负责处理 Agent 返回的原始文本,分离思考内容和正文。
|
||||||
|
|
||||||
模型思考标记格式(按优先级检测):
|
SSE 实际输出格式(从日志确认):
|
||||||
1. <think...</think 标签对(MiniMax M2.7 等模型)
|
- " thinking\\n...思考内容...\\n response\\n...正文内容..." (纯文本标记)
|
||||||
2. 💭 emoji 前缀(旧版格式,保留兼容)
|
- 思考部分: " thinking\\n" 开头
|
||||||
|
- 正文部分: "\\n response\\n" 之后的所有内容
|
||||||
|
|
||||||
ADK 最后一个 event 会重复发送完整内容,需要去重。
|
多轮调用时 full_text 会累加,调用方需要自行切分轮次。
|
||||||
"""
|
"""
|
||||||
import logging
|
|
||||||
import re
|
import re
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
# 思考-正文分隔标记:出现在文本中时,之前是思考,之后是正文
|
||||||
|
_RESPONSE_MARKER = re.compile(r'\n response\n')
|
||||||
|
|
||||||
|
|
||||||
def extract_final_reply(text: str) -> str:
|
def parse_reply(text: str) -> tuple[str, str]:
|
||||||
"""
|
"""
|
||||||
从 SSE 收集的完整文本中提取最终正式回复。
|
从文本中分离思考内容和正文。
|
||||||
|
|
||||||
策略:
|
支持两种格式:
|
||||||
1. 去除 <think...</think 标签及其内容
|
1. " thinking\\n...\\n response\\n..." (SSE 实际纯文本格式)
|
||||||
2. 兼容 💭 emoji 分割(旧版格式)
|
2. " thinking... response" (HTML 标签风格)
|
||||||
3. 去重
|
|
||||||
|
返回: (思考内容, 正文内容)
|
||||||
"""
|
"""
|
||||||
if not text:
|
if not text:
|
||||||
return text
|
return "", ""
|
||||||
|
|
||||||
# 步骤1:去除 <think...</think 标签块
|
think_content = ""
|
||||||
if "<think" in text:
|
body_content = text
|
||||||
text = re.sub(r"<think[\s\S]*?</think\s*>?", "", text)
|
|
||||||
|
|
||||||
# 步骤2:兼容 💭 emoji 分割(旧版格式)
|
# 格式1:匹配 " thinking\\n ... \\n response\\n" 纯文本标记
|
||||||
if "💭" in text:
|
# 从 SSE 日志确认: 思考内容以 "\\n response\\n" 结束
|
||||||
text = text.split("💭")[-1]
|
parts = _RESPONSE_MARKER.split(text, maxsplit=1)
|
||||||
|
if len(parts) == 2:
|
||||||
|
think_part, body_part = parts
|
||||||
|
# 去掉开头的 " thinking\\n"
|
||||||
|
think_part = think_part.strip()
|
||||||
|
if think_part.startswith(' thinking'):
|
||||||
|
think_part = think_part[len(' thinking'):].lstrip('\n')
|
||||||
|
think_content = think_part.strip()
|
||||||
|
body_content = body_part.strip()
|
||||||
|
|
||||||
# 清理前导空白
|
# 格式2:匹配 " thinking... response" HTML 标签风格(兜底)
|
||||||
text = text.strip()
|
if not think_content and "<think" in text:
|
||||||
|
match = re.search(r"<think([\s\S]*?)</think\s*>", text)
|
||||||
|
if match:
|
||||||
|
think_content = match.group(1).strip()
|
||||||
|
body_content = re.sub(r"<think[\s\S]*?</think\s*>", "", text).strip()
|
||||||
|
else:
|
||||||
|
think_content = text.replace("<think", "").strip()
|
||||||
|
body_content = ""
|
||||||
|
|
||||||
# 步骤3:去重
|
# 去重
|
||||||
text = _deduplicate(text)
|
body_content = _deduplicate(body_content)
|
||||||
|
think_content = _deduplicate(think_content)
|
||||||
|
|
||||||
return text
|
return think_content, body_content
|
||||||
|
|
||||||
|
|
||||||
|
def extract_body_only(text: str) -> str:
|
||||||
|
"""
|
||||||
|
从文本中只提取正文内容(不含思考)。
|
||||||
|
用于流式更新时过滤思考内容。
|
||||||
|
|
||||||
|
如果文本中还没有出现 "\\n response\\n" 标记,
|
||||||
|
说明还在思考阶段,返回空字符串避免把思考内容当正文发送。
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# 检查正文标记是否已出现
|
||||||
|
split_pos = _find_response_marker(text)
|
||||||
|
if split_pos < 0:
|
||||||
|
# 还没出现 response 标记,仍在思考阶段,不返回任何内容
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# 返回 response 之后的内容
|
||||||
|
body = text[split_pos + 1:]
|
||||||
|
# 去掉开头的 response\n
|
||||||
|
body = body.lstrip('\n')
|
||||||
|
if body.startswith(' response'):
|
||||||
|
body = body[len(' response'):].lstrip('\n')
|
||||||
|
|
||||||
|
# 再次检查是否有新的 response 标记(多轮场景)
|
||||||
|
pos2 = _find_response_marker(body)
|
||||||
|
if pos2 >= 0:
|
||||||
|
body = body[pos2 + 1:]
|
||||||
|
body = body.lstrip('\n')
|
||||||
|
if body.startswith(' response'):
|
||||||
|
body = body[len(' response'):].lstrip('\n')
|
||||||
|
|
||||||
|
return body.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _find_response_marker(text: str) -> int:
|
||||||
|
"""查找 \\n response\\n 标记的位置,返回 response 中 'r' 的索引"""
|
||||||
|
m = _RESPONSE_MARKER.search(text)
|
||||||
|
return m.start() if m else -1
|
||||||
|
|
||||||
|
|
||||||
def _deduplicate(text: str) -> str:
|
def _deduplicate(text: str) -> str:
|
||||||
"""
|
"""对回复文本去重。"""
|
||||||
对回复文本去重。
|
|
||||||
ADK 最后一个 SSE event 会重复发送完整内容(含思考+回复),
|
|
||||||
去除 <think...> 后可能出现连续两份相同的正式回复。
|
|
||||||
支持行级和字符级去重。
|
|
||||||
"""
|
|
||||||
if not text:
|
if not text:
|
||||||
return text
|
return text
|
||||||
|
|
||||||
@@ -66,13 +119,13 @@ def _deduplicate(text: str) -> str:
|
|||||||
if non_empty[-overlap_len:] == non_empty[:overlap_len]:
|
if non_empty[-overlap_len:] == non_empty[:overlap_len]:
|
||||||
return "\n".join(non_empty[overlap_len:])
|
return "\n".join(non_empty[overlap_len:])
|
||||||
|
|
||||||
# 字符级去重:检查文本是否由两个相同的子串拼接而成
|
# 字符级去重
|
||||||
length = len(text)
|
length = len(text)
|
||||||
for sub_len in range(length // 2, 0, -1):
|
for sub_len in range(length // 2, 0, -1):
|
||||||
if length % sub_len == 0 and text[:sub_len] * (length // sub_len) == text:
|
if length % sub_len == 0 and text[:sub_len] * (length // sub_len) == text:
|
||||||
return text[:sub_len]
|
return text[:sub_len]
|
||||||
|
|
||||||
# 部分重叠:前缀和后缀相同,中间有重复
|
# 部分重叠
|
||||||
for overlap in range(min(length // 2, 500), 0, -1):
|
for overlap in range(min(length // 2, 500), 0, -1):
|
||||||
if text[:overlap] == text[-overlap:]:
|
if text[:overlap] == text[-overlap:]:
|
||||||
trimmed = text[overlap:]
|
trimmed = text[overlap:]
|
||||||
|
|||||||
Reference in New Issue
Block a user