AI写作:技能检索进度反馈与超时兜底
检索等待时 SkillStepsCard 显示 spinner;单技能检索 25s 超时避免卡死;前后端增加 skill-timing 调试日志。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
23d6f9efc2
commit
633a350bfc
4 files changed
+65
-11
No files matched your search
+24
-4
@@ -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}")
|
||||
|
||||
+8
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user