AI写作:技能检索进度反馈与超时兜底

检索等待时 SkillStepsCard 显示 spinner;单技能检索 25s 超时避免卡死;前后端增加 skill-timing 调试日志。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-05-31 14:42:51 +08:00
1 parent 23d6f9efc2
commit 633a350bfc
4 files changed
+65 -11

No files matched your search

@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio
import logging
import re
import time
import uuid
from typing import List
@@ -29,6 +30,11 @@ _MAX_MAX_MATERIALS = 100
_DEFAULT_KEYWORD_COUNT = 6
_MAX_KEYWORD_COUNT = 10
# 单个技能的 web 检索超时(秒)。技能检索是顺序循环、最多 5 个技能,
# DuckDuckGo 等 provider 偶发限速/挂起会把整段"技能检索过程"拖到几十秒看起来卡死;
# 超时即放弃该技能、emit 0 条结果继续下一个,保证时间线持续推进。
_SKILL_SEARCH_TIMEOUT_SECONDS = 25
def _resolve_keyword_count(raw: object) -> int:
"""把用户传入的检索关键词数量规整到 [1, _MAX_KEYWORD_COUNT],非法值回退默认 6。"""
@@ -178,6 +184,10 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
def _emit_skill_step(step: dict) -> None:
skill_steps.append(step)
# 技能检索时机 trace:grep "[skill-timing]" 看每步 emit 的真实时刻,
# 与前端 [ai-writing:custom] 日志对齐,钉死是否实时推送。
logger.info("[skill-timing] t=%.3f emit skill_search_step type=%s skill=%s",
time.monotonic(), step.get("type"), step.get("skill_name") or "")
writer({"type": "skill_search_step", "agent_name": "素材收集专家", **step})
if is_skill_search:
@@ -205,15 +215,25 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
for entry in selected:
sk = entry["skill"]
# 用 LLM 把 user_query + skill.description 转成针对性关键词
skill_query = f"{user_query} {sk.description or ''}".strip() or intent
# invoke_skill 步骤带上实际检索词(截断防过长),让用户在每次长 await 之前
# 就看到"这一步在用技能 X 检索什么",配合前端 spinner 体现进行中。
_emit_skill_step({
"type": "invoke_skill",
"message": f"使用技能「{sk.name}」开始检索…",
"message": f"使用技能「{sk.name}」检索:{skill_query[:60]}",
"skill_name": sk.name,
})
skill_query = f"{user_query} {sk.description or ''}".strip() or intent
try:
res = await web_provider.search(skill_query, max_results=per_skill)
# 加超时兜底:单个技能检索最多等 _SKILL_SEARCH_TIMEOUT_SECONDS 秒,
# 超时不再阻塞后续技能,避免整段过程看起来"冻结"。
res = await asyncio.wait_for(
web_provider.search(skill_query, max_results=per_skill),
timeout=_SKILL_SEARCH_TIMEOUT_SECONDS,
)
except asyncio.TimeoutError:
logger.warning("skill[%s] search timed out after %ss", sk.name, _SKILL_SEARCH_TIMEOUT_SECONDS)
search_errors.append(f"skill[{sk.name}]: 检索超时(>{_SKILL_SEARCH_TIMEOUT_SECONDS}s)")
res = []
except Exception as e:
logger.warning("skill[%s] search failed: %s", sk.name, e)
search_errors.append(f"skill[{sk.name}]: {e}")
@@ -16,6 +16,7 @@
from __future__ import annotations
import logging
import time
from typing import Callable, List, Optional
from langchain_core.messages import HumanMessage, SystemMessage
@@ -140,7 +141,14 @@ class SkillPicker:
parser = ThinkTagParser()
full_text = ""
_chunk_count = {"n": 0}
def _emit_chunk(text: str, is_thinking: bool) -> None:
# 只在首个 chunk 打一条 timing,避免逐 token 刷屏;用来和 skill_search_step
# 的 [skill-timing] 对比"选择打字"与"步骤事件"谁先到。
if _chunk_count["n"] == 0:
logger.info("[skill-timing] t=%.3f first skill_select_chunk", time.monotonic())
_chunk_count["n"] += 1
writer({
"type": "skill_select_chunk",
"text": text,