feat: add structured execution trace SSE events (status/tool_start/tool_end/tool_error)

Add CoT execution trace to the SSE stream: status events for stage
transitions, enriched tool_start with input_summary, tool_end with
output_summary and duration_ms, and tool_error for failed tool calls.
Helper functions _sse, _summarize_input, _summarize_output provide
human-readable summaries for each tool type.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gongzhiyong
2026-04-09 13:00:49 +08:00
co-authored by Claude Sonnet 4.6
parent fe8c361127
commit 722ec3e81d
+136 -18
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import json
import logging
import time
import uuid
from collections.abc import AsyncIterator
@@ -79,8 +80,14 @@ async def _stream_response(request: ChatRequest) -> AsyncIterator[bytes]:
full_content: list[str] = []
logger = logging.getLogger(__name__)
tool_start_ts: dict[str, int] = {} # track per-tool start timestamps
has_tool_activity = False # whether any tool has been called
final_status_emitted = False # whether "整理答案" status was emitted
try:
# Emit initial status before graph starts
yield _sse({"type": "status", "stage": "分析问题", "message": "正在理解您的问题..."})
async for event in graph.astream_events(
input_data,
config=config,
@@ -93,29 +100,56 @@ async def _stream_response(request: ChatRequest) -> AsyncIterator[bytes]:
if chunk and hasattr(chunk, "content") and chunk.content:
# Only stream text content, skip tool call chunks
if isinstance(chunk.content, str):
# Before first token after tool activity, emit "整理答案" status
if has_tool_activity and not final_status_emitted:
yield _sse({
"type": "status",
"stage": "整理答案",
"message": "正在结合检索结果生成回复...",
})
final_status_emitted = True
full_content.append(chunk.content)
sse_data = json.dumps(
{"type": "token", "content": chunk.content},
ensure_ascii=False,
)
yield f"data: {sse_data}\n\n".encode("utf-8")
yield _sse({"type": "token", "content": chunk.content})
elif kind == "on_tool_start":
# Notify frontend that a tool is being called
tool_name = event.get("name", "unknown")
sse_data = json.dumps(
{"type": "tool_start", "tool": tool_name},
ensure_ascii=False,
)
yield f"data: {sse_data}\n\n".encode("utf-8")
tool_input = event.get("data", {}).get("input", {})
ts = int(time.time() * 1000)
tool_start_ts[tool_name] = ts
has_tool_activity = True
yield _sse({
"type": "tool_start",
"tool": tool_name,
"title": _TOOL_TITLES.get(tool_name, tool_name),
"input_summary": _summarize_input(tool_name, tool_input),
"ts": ts,
})
elif kind == "on_tool_end":
tool_name = event.get("name", "unknown")
sse_data = json.dumps(
{"type": "tool_end", "tool": tool_name},
ensure_ascii=False,
)
yield f"data: {sse_data}\n\n".encode("utf-8")
output = event.get("data", {}).get("output", "")
output_str = output if isinstance(output, str) else str(output)
ts = int(time.time() * 1000)
duration_ms = ts - tool_start_ts.pop(tool_name, ts)
if _is_tool_error(output_str):
yield _sse({
"type": "tool_error",
"tool": tool_name,
"title": _TOOL_TITLES.get(tool_name, tool_name),
"error_summary": _extract_error_summary(output_str),
"duration_ms": duration_ms,
"ts": ts,
})
else:
yield _sse({
"type": "tool_end",
"tool": tool_name,
"title": _TOOL_TITLES.get(tool_name, tool_name),
"output_summary": _summarize_output(tool_name, output_str),
"status": "success",
"duration_ms": duration_ms,
"ts": ts,
})
except Exception as exc:
logger.error("SSE stream error for conversation %s: %s", request.conversation_id, exc, exc_info=True)
@@ -156,8 +190,92 @@ async def _stream_response(request: ChatRequest) -> AsyncIterator[bytes]:
await _persist_ai_message(request.conversation_id, ai_content)
# Always send done so the frontend closes the stream cleanly
done_data = json.dumps({"type": "done"})
yield f"data: {done_data}\n\n".encode("utf-8")
yield _sse({"type": "done"})
# ── Trace helpers ─────────────────────────────────────────────────────────────
def _sse(data: dict) -> bytes:
return f"data: {json.dumps(data, ensure_ascii=False)}\n\n".encode("utf-8")
_TOOL_TITLES: dict[str, str] = {
"kb_search": "检索知识库",
"ticket_list": "查询工单列表",
"ticket_detail": "查询工单详情",
"web_search": "外部搜索",
"generate_document": "生成文档",
"sandbox_run": "执行沙盒代码",
}
_ERROR_KEYWORDS = (
"出错", "失败", "超时", "error", "failed", "timeout",
"not available", "no download link", "execution failed",
)
def _is_tool_error(output: str) -> bool:
lo = output.lower()
return any(kw in lo for kw in _ERROR_KEYWORDS)
def _extract_error_summary(output: str) -> str:
first_line = output.split("\n")[0].strip()
return first_line[:80] if first_line else "工具调用失败"
def _summarize_input(tool_name: str, inp: dict | str) -> str:
if isinstance(inp, str):
return inp[:60]
match tool_name:
case "kb_search":
return f"查询:{str(inp.get('query', ''))[:50]}"
case "ticket_list":
return f"第 {inp.get('page', 1)} 页,每页 {inp.get('page_size', 20)} 条"
case "ticket_detail":
return f"工单 ID:{inp.get('ticket_id', '')}"
case "web_search":
return f"搜索:{str(inp.get('query', ''))[:50]}"
case "generate_document":
return str(inp.get('prompt', ''))[:60]
case "sandbox_run":
lang = inp.get("language", "python")
lines = len(str(inp.get("code", "")).splitlines())
return f"{lang} 代码({lines} 行)"
case _:
return str(inp)[:60]
def _summarize_output(tool_name: str, output: str) -> str:
if not output or output.strip() in ("", "(no output)"):
return "无结果"
match tool_name:
case "kb_search":
count = output.count("---") + 1 if "---" in output else 1
return f"命中 {count} 条知识库记录"
case "ticket_list":
import re as _re
m = _re.search(r"Found (\d+) tickets", output)
return f"返回 {m.group(1)} 条工单" if m else "工单列表已获取"
case "ticket_detail":
return "工单详情已获取"
case "web_search":
count = output.count("##")
return f"找到 {max(count, 1)} 条搜索结果"
case "generate_document":
if "Download:" in output:
if "[PPT]" in output:
return "PPT 已生成,可下载"
if "[Excel]" in output or "[Table]" in output:
return "表格已生成,可下载"
return "Word 文档已生成,可下载"
return "文档生成完成"
case "sandbox_run":
lines = len(output.splitlines())
has_error = output.startswith("[Exit code:")
return f"执行完成,输出 {lines} 行{'(含错误)' if has_error else ''}"
case _:
return output[:60]
@post("/api/chat/stream")