[agent] Deep Research - Web Service Implementation

Link: https://github.com/ShenSeanChen/launch-DeepResearch-Backend

 

 

API 总览


当前 main.py 定义了 9 个业务路径、11 个 HTTP 操作。之所以是 11 个,是因为 //health 同时支持 GET、HEAD。

方法路径功能
GET / HEAD / 基础存活检查
GET / HEAD /health 检查后端及内部服务状态
POST /research/stream 执行一次 Deep Research,并通过 SSE 实时返回过程
GET /models 获取支持的模型列表
GET /research/history 获取研究历史
GET /research/comparison 获取不同模型的历史性能统计
DELETE /research/history/{research_id} 删除指定研究记录
POST /research/compare 用多个模型并行研究同一个问题并比较
POST /research/test 测试研究请求参数,不真正运行 Graph

此外,FastAPI 自动提供:

路径功能
/docs Swagger 交互式 API 文档
/redoc ReDoc API 文档
/openapi.json OpenAPI 接口定义

 

 

 

执行一次 Deep Research


发起一次Query

{
  "query": "分析人工智能对软件开发的影响",
  "model": "openai",
  "api_key": "用户提供的 API Key"
}

 

调用过程 

POST /research/stream
        ↓
DeepResearchService.stream_research()
        ↓
LangGraph deep_researcher
        ↓
模型调用、搜索、MCP Tools
        ↓
StreamingEvent
        ↓
SSE 返回前端

 

 

入口分析

REST API 入口

@app.post("/research/stream")
async def stream_research(request: ResearchRequest):
    """
    Stream deep research process with real-time updates
    
    This endpoint provides Server-Sent Events streaming of the research process,
    showing each stage, thinking process, and tool usage in real-time.
    
    Args:
        request: Research request containing query, model, and API key
    
    Returns:
        StreamingResponse: Server-sent events stream
    """
    try:
        # Validate the request
        if not request.query.strip():
            raise HTTPException(status_code=400, detail="Research query cannot be empty")
        
        if not request.api_key.strip():
            raise HTTPException(status_code=400, detail="API key is required")
        
        # Validate model selection
        available_models = await model_service.get_available_models()
        if request.model not in [model.id for model in available_models["models"]]:
            raise HTTPException(
                status_code=400, 
                detail=f"Unsupported model: {request.model}. Available models: {[m.id for m in available_models['models']]}"
            )
        
        # =====================================================================
        # 从这里开始,是这个接口最重要、也最容易让初学者困惑的部分:
        # 我们不等待整份研究报告生成完毕,而是创建一个“异步生成器”,
        # 让 FastAPI 可以从它那里一条一条地取出研究进度,再实时发给前端。
        # =====================================================================
        async def generate_research_stream() -> AsyncGenerator[str, None]:
            """把一次 Deep Research 的全过程,转换成前端能够持续接收的 SSE 消息流。

            请先抓住这条主线:

                LangGraph / DeepResearchService 产生研究事件
                                ↓
                generate_research_stream 逐条拿到事件
                                ↓
                把事件转换成 ``data: JSON\n\n``
                                ↓
                StreamingResponse 将它发送给前端

            为什么它写在 stream_research() 里面?
                它是一个“嵌套函数”,因此可以直接读取外层的 request.query、
                request.model 和 request.api_key,不需要再把这些值传进来。

            ``async`` 表示什么?
                研究过程需要等待模型、搜索服务和 MCP 工具。等待网络结果时,
                ``async`` 允许事件循环先处理其他请求,不必一直占住当前线程。

            ``AsyncGenerator[str, None]`` 表示什么?
                - ``str``:每一次 yield 交出去的内容都是字符串,也就是一条 SSE 消息;
                - ``None``:调用方不会通过 ``asend()`` 向生成器反向传入业务数据。

            最关键的一点:调用 generate_research_stream() 时,下面的代码不会立刻
            从头跑到尾。它只会创建一个异步生成器对象。真正开始执行,是后面的
            StreamingResponse 开始向这个生成器索取第一条消息的时候。
            """

            # -----------------------------------------------------------------
            # 第 1 步:给这次研究建立“身份”,并按下计时器。
            # -----------------------------------------------------------------
            # research_id 会被放进后续的每一类重要事件中。前端拿到它以后,就能知道
            # 当前消息属于哪一次研究;指标服务也可以用它标识这一条研究记录。
            research_id = f"research_{int(time.time())}"

            # start_time 使用时间戳记录开始时刻。研究完成后用“结束时间 - 开始时间”,
            # 就能算出整个研究流程持续了多少秒。
            start_time = time.time()
            
            try:
                # -------------------------------------------------------------
                # 第 2 步:先发送 session_start,告诉前端“研究会话已经建立”。
                # -------------------------------------------------------------
                # 此时真正的 Graph 研究可能还没有产出任何内容,但前端已经可以:
                # 1. 保存 research_id;
                # 2. 显示正在研究的模型和问题;
                # 3. 把界面从“正在连接”切换成“研究已开始”。
                #
                # 一条合法的 SSE 消息看起来是:
                #
                #     data: {"type": "session_start", ...}\n\n
                #
                # ``data: `` 是 SSE 的数据字段标记;json.dumps() 把 Python 字典变成
                # JSON 字符串;最后两个换行符 ``\n\n`` 表示“这一条 SSE 消息结束了”。
                # 如果没有最后的空行,浏览器可能会继续等待,认为消息还没有写完。
                yield f"data: {json.dumps({'type': 'session_start', 'research_id': research_id, 'timestamp': datetime.utcnow().isoformat(), 'model': request.model, 'query': request.query})}\n\n"

                # ``yield`` 和 ``return`` 完全不同:
                # - return:结束整个函数;
                # - yield:交出当前这一条消息,然后把函数暂停在这里。
                #
                # 当 StreamingResponse 下一次来取数据时,函数才从这个 yield 的下一行
                # 继续执行。这正是“边生产、边发送”能够实现的根本原因。
                
                # -------------------------------------------------------------
                # 第 3 步:进入真正的研究事件流,等待 Graph 一步一步向外报告进展。
                # -------------------------------------------------------------
                # 注意职责边界:generate_research_stream() 自己不做研究推理,也不决定
                # Graph 下一个节点去哪里。真正的 LangGraph 工作流在
                # research_service.stream_research() 的更下游执行。
                #
                # 这里传入本次研究需要的四项上下文:
                # - query:用户究竟想研究什么;
                # - model:选择哪个模型;
                # - api_key:调用该模型所需的凭据;
                # - research_id:让下游生成的事件都属于同一次研究。
                async for event in research_service.stream_research(
                    query=request.query,
                    model=request.model,
                    api_key=request.api_key,
                    research_id=research_id
                ):
                    # ``async for`` 可以把它理解成:
                    #
                    #     “下游每准备好一个事件,就把它交给我;
                    #      如果下一个事件还没有准备好,我就异步等待。”
                    #
                    # 等待模型或工具返回时,它不会用一个死循环不断询问,也不会阻塞
                    # 整个 FastAPI 服务。下游一旦 yield 一个 StreamingEvent,这个循环
                    # 就会执行一次。

                    # ---------------------------------------------------------
                    # 第 4 步:把下游的每一个研究事件,立即转发成一条 SSE 消息。
                    # ---------------------------------------------------------
                    # event 是 Pydantic 的 StreamingEvent 对象,event.dict() 先把它变成
                    # 普通 Python 字典,json.dumps() 再把字典变成可通过网络发送的 JSON。
                    # 外面补上 ``data: `` 和 ``\n\n`` 后,前端就能把它当作一条完整的
                    # SSE 事件接收,而不用等到整个研究报告完成。
                    yield f"data: {json.dumps(event.dict())}\n\n"

                    # 每执行一次这里的 yield,控制权都会暂时回到 StreamingResponse:
                    #
                    #     Graph 产生一个 event
                    #
                    #     本函数包装并 yield
                    #
                    #     StreamingResponse 发给前端
                    #
                    #     前端更新一次页面
                    #
                    #     本函数继续等待 Graph 的下一个 event
                
                # -------------------------------------------------------------
                # 第 5 步:async for 自然结束,说明下游不会再产生研究事件。
                # -------------------------------------------------------------
                # 这里能够继续往下执行,并不只是因为“时间到了”,而是因为
                # research_service.stream_research() 已经结束了它自己的异步事件流。
                # 至于 Graph 为什么结束——完成了研究、达到了循环限制,还是走到其他
                # 终止条件——是下游 Graph 的责任,不是这个 SSE 生成器在这里判断的。

                # 记录结束时间,并计算从第 1 步到现在一共花了多少秒。
                end_time = time.time()
                duration = end_time - start_time
                
                # -------------------------------------------------------------
                # 第 6 步:补发一个 research_complete,明确通知前端“整条流完成了”。
                # -------------------------------------------------------------
                # 下游事件循环结束只对后端代码可见,前端并不知道它为什么不再有消息。
                # 因此,这一层主动构造完成事件,让前端可以停止 loading、显示总耗时,
                # 并把研究状态标记为 completed。
                completion_event = {
                    'type': 'research_complete',
                    'research_id': research_id,
                    'duration': duration,
                    'model': request.model,
                    'timestamp': datetime.utcnow().isoformat()
                }
                yield f"data: {json.dumps(completion_event)}\n\n"

                # 注意:上面仍然是 yield。完成事件交出去以后,本函数会再次暂停。
                # 当 StreamingResponse 再来索取下一条消息时,才会从下面继续执行,
                # 保存指标,并最终走到函数末尾,让整个 SSE 响应真正结束。
                
                # -------------------------------------------------------------
                # 第 7 步:保存本次研究的基本指标,供历史记录和模型比较接口使用。
                # -------------------------------------------------------------
                # 这一步不会改变研究报告;它只是记录“谁研究了什么、用了哪个模型、
                # 总共花了多久”。之后 /research/history 和 /research/comparison 可以
                # 使用这些数据。await 表示保存尚未完成时,在这里异步等待结果。
                await metrics_collector.store_research_metrics(
                    research_id=research_id,
                    model=request.model,
                    duration=duration,
                    query=request.query
                )
                
            except Exception as e:
                # -------------------------------------------------------------
                # 第 8 步:流式过程中出错时,把异常转换成一条 error SSE 事件。
                # -------------------------------------------------------------
                # 为什么这里不是简单地 raise HTTPException(500)?
                # 因为 session_start 或研究进度很可能已经通过网络发出去了,此时 HTTP
                # 响应头通常已经是“200 + text/event-stream”,无法再把整个响应改成
                # 普通的 500 JSON 响应。所以我们沿用同一条 SSE 通道通知前端失败。
                # 前端必须检查事件中的 type == "error",不能只看 HTTP 状态码。
                logger.error(f"Error in research stream: {str(e)}")
                error_event = {
                    'type': 'error',
                    'message': str(e),
                    'research_id': research_id,
                    'timestamp': datetime.utcnow().isoformat()
                }
                yield f"data: {json.dumps(error_event)}\n\n"
        
        # ---------------------------------------------------------------------
        # 这里虽然写了 generate_research_stream(),但它只创建“异步生成器对象”。
        # StreamingResponse 接管这个对象后,才会不断向它索取下一条字符串;每索取
        # 一次,生成器就向前运行到下一个 yield。这样 HTTP 连接可以保持打开,并把
        # 研究进度一条一条发送出去,而不是等最终报告全部完成后一次性返回。
        # ---------------------------------------------------------------------
        return StreamingResponse(
            generate_research_stream(),
            media_type="text/event-stream",
            headers={
                "Cache-Control": "no-cache",
                "Connection": "keep-alive",
                "Access-Control-Allow-Origin": "*",
                "X-Accel-Buffering": "no",  # Disable nginx buffering
                "Keep-Alive": "timeout=300, max=100",  # 5 minute timeout
                "X-Content-Type-Options": "nosniff",
            }
        )
        
    except Exception as e:
        logger.error(f"Error starting research stream: {str(e)}")
        raise HTTPException(status_code=500, detail=str(e))

 

服务入口

open_deep_research 没有自己编写 astream() 函数;但它创建出来的 deep_researcher 对象,拥有 LangGraph 提供的 astream() 方法。

RunnableConfig(
    configurable={
        # 真正控制 Graph 怎么运行
        "research_model": "claude-sonnet-4-6",
        "max_researcher_iterations": 1,
        ...
    },

    metadata={
        # 给这次运行贴标签
        "model_type": "anthropic",
        "research_id": "research_1788519682"
    }
)

下面是是关键设计。

config_dict = {

    # ---------------------------
    # 1. Research Model
    # ---------------------------

    # Researcher 阶段使用的主模型。
    # 为什么单独定义:
    # Deep Research 不一定要求“调查”和“最终写报告”使用同一个模型。
    # 研究阶段更关注搜索、分析、调用工具、证据整理,
    # 因此可以单独选择一个更适合 reasoning / tool use 的模型。
    "research_model": "claude-sonnet-4-6",

    # 限制一次 Researcher 模型调用最多生成多少 token。
    # 为什么需要:
    # 防止单次研究输出过长,控制成本和上下文膨胀。
    "research_model_max_tokens": 4000,


    # ---------------------------
    # 2. Final Report Model
    # ---------------------------

    # 最终报告生成阶段使用的模型。
    # 为什么单独定义:
    # 前面的 Researcher 负责“找资料、做分析”,
    # Final Report 更关注“整合所有研究结果并写成完整答案”。
    # 两个阶段的任务性质不同,因此允许使用不同模型。
    "final_report_model": "claude-sonnet-4-6",

    # Final Report 通常比单个 Researcher 输出更长,
    # 因为它要整合所有研究结果,所以这里给了更大的 token 上限。
    "final_report_model_max_tokens": 8000,


    # ---------------------------
    # 3. Compression Model
    # ---------------------------

    # 用于压缩 Researcher 原始研究结果的模型。
    #
    # 为什么需要 Compression:
    # Researcher 搜索和工具调用后可能产生大量原始内容。
    # 如果全部直接传回 Supervisor,
    # 会快速消耗 context window。
    #
    # 因此先进行:
    # raw research result
    #
    # compression_model
    #
    # 更短、更集中的研究结果
    "compression_model": "claude-sonnet-4-6",

    # 控制压缩结果最大长度。
    "compression_model_max_tokens": 4000,


    # ---------------------------
    # 4. Summarization Model
    # ---------------------------

    # 用于 summarization 的模型。
    #
    # 它和 compression 有一点相似,但职责不同:
    #
    # Compression:
    #   更偏向压缩某次 Researcher / Tool 的研究内容。
    #
    # Summarization:
    #   更偏向对较长的历史信息、上下文或中间结果做摘要,
    #   让后续节点继续工作时不会携带过多历史内容。
    "summarization_model": "claude-sonnet-4-6",

    # Summarization 最大输出长度。
    "summarization_model_max_tokens": 4000,


    # ---------------------------
    # 5. Clarification
    # ---------------------------

    # 是否允许 Agent 在研究开始前向用户追问。
    #
    # True:
    # 用户问题不清楚时,可以先 clarification,
    # 再生成 research brief。
    #
    # False:
    # 不追问,直接根据当前问题开始研究。
    #
    # 当前项目设置为 False,
    # 所以 clarify_with_user 节点通常会直接继续到 research brief。
    "allow_clarification": False,


    # ---------------------------
    # 6. Structured Output Retry
    # ---------------------------

    # LLM 输出结构化数据失败时最多重试多少次。
    #
    # 为什么需要:
    # Deep Research 中不少节点可能要求模型返回固定 schema,
    # 例如:
    # - research brief
    # - supervisor decision
    # - tool call
    #
    # LLM 偶尔会返回格式不合法的数据,
    # 所以这里允许自动 retry。
    "max_structured_output_retries": 2,


    # ---------------------------
    # 7. Search Provider
    # ---------------------------

    # Researcher 需要搜索外部信息时使用哪个 Search API。
    #
    # 这里选择 anthropic,
    # 表示搜索能力由 Anthropic 这一套 provider/tool 路径提供。
    #
    # 为什么要独立配置:
    # 模型 provider 和搜索 provider 理论上可以不同。
    "search_api": "anthropic",


    # ---------------------------
    # 8. Supervisor / Researcher Loop Limit
    # ---------------------------

    # Supervisor 最多允许进行多少轮 Researcher 调度。
    #
    # 为什么需要:
    # Deep Research 是循环结构:
    #
    # Supervisor
    #
    # Researcher
    #
    # 返回结果
    #
    # Supervisor 决定是否继续
    #
    # 如果没有上限,
    # Agent 可能不断继续研究,导致成本和时间失控。
    "max_researcher_iterations": 1,


    # ---------------------------
    # 9. ReAct Tool Call Limit
    # ---------------------------

    # 一个 Researcher 在 ReAct 循环中最多可以调用多少次 Tool。
    #
    # 例如:
    #
    # Researcher
    #
    # search
    #
    # think
    #
    # search again
    #
    # fetch source
    #
    # max_react_tool_calls 用于限制这个内部工具循环的深度。
    "max_react_tool_calls": 3,


    # ---------------------------
    # 10. Concurrent Research Units
    # ---------------------------

    # 最多同时运行多少个 Researcher。
    #
    # Deep Research 的 Supervisor 可以把一个复杂问题拆成多个子任务:
    #
    #                  Supervisor
    #                  /        \
    #          Researcher A    Researcher B
    #
    # max_concurrent_research_units
    # 决定最多允许多少个 Researcher 并行执行。
    #
    # 越大:
    # - 速度可能更快
    # - API 成本和并发压力也更高
    "max_concurrent_research_units": 2,


    # ---------------------------
    # 11. User API Key
    # ---------------------------

    # 当前用户请求所使用的 Provider API Key。
    #
    # 为什么放到 config:
    # Graph 内部不同节点 / model / tool 都可能需要访问 provider,
    # 因此通过 RunnableConfig 让整个 Graph runtime 都可以读取。
    #
    # 注意:
    # 日志中必须始终 redacted,不能输出真实 Key。
    "user_api_key": "<redacted>",


    # ---------------------------
    # 12. Model Provider
    # ---------------------------

    # 明确告诉系统:
    # research_model 属于哪个 provider。
    #
    # 为什么模型名和 provider 要分开:
    # 单独保存 provider 可以让 model loading / API initialization
    # 不依赖于“从模型名字猜 provider”。
    "research_model_provider": "anthropic",

    # Final Report 模型使用的 provider。
    "final_report_model_provider": "anthropic",

    # Compression 模型使用的 provider。
    "compression_model_provider": "anthropic",

    # Summarization 模型使用的 provider。
    "summarization_model_provider": "anthropic",
}

 

如果工具中例如要调用数据,需要用户ID,那么可以把针对的目标客户的信息作为新的成员变量于类AgentInputState。

 

class DeepResearchService:
    """Service for handling deep research operations with streaming support"""

一个函数只讲一件事;同一个函数里的代码尽量处在同一个抽象层级。

async def stream_research(
    self,
    query: str,
    model: ModelType,
    api_key: str,
    research_id: str,
):
    """
    Service 总入口。

    这里只负责“大流程编排”,不处理 chunk 内部细节:

        准备 Graph
            ↓
        发送初始化事件
            ↓
        运行 Graph
            ↓
        转发 Graph 产生的 SSE
            ↓
        完成 / 异常处理
    """

    try:
        config = await self._create_research_config(
            model=model,
            api_key=api_key,
            research_id=research_id,
        )

        initial_state = self._create_initial_state(query)

        for event in self._create_initial_events(
            research_id=research_id,
            model=model,
        ):
            yield event

        runtime = self._create_stream_runtime()

        async for event in self._stream_main_graph(
            initial_state=initial_state,
            config=config,
            runtime=runtime,
            research_id=research_id,
            model=model,
        ):
            yield event

    except Exception as e:
        yield self._create_error_event(
            error=e,
            research_id=research_id,
            model=model,
        )

只负责 Graph ↔ chunk 循环。

async def _stream_main_graph(
    self,
    initial_state,
    config,
    runtime,
    research_id,
    model,
):
    """
    Main Graph streaming loop。

    Graph 跑一步
        ↓
    返回一个 chunk
        ↓
    chunk 转成 SSE
        ↓
    再等待 Graph 下一步
    """

    async for chunk in deep_researcher.astream(
        initial_state,
        config=config,
        stream_mode="updates",
    ):
        runtime.start_chunk()

     # 里面可能会产生多个 event,这里一个个拿出来,再继续 yield 给外面。 async
for event in self._handle_graph_chunk( chunk, runtime, research_id, model, ): yield event if runtime.should_stop: break
 
把一个已经执行完成的 LangGraph node update, 转换成一个或多个前端 StreamingEvent。 为什么单独设计这一层?
LangGraph 返回的是: node_name + node_data
但前端需要的不是 LangGraph 原始数据,而是 SSE event。
而且: 一个 Graph node update 不一定只产生一个前端 event。
例如 research_supervisor 可能产生:
        1. 标准 stage_update
        2. sources_found
        3. planning / thinking / analysis 等额外事件
所以这个函数承担的是: one node update --> 0 ~ N StreamingEvents
注意: - 这里不会再次执行 LangGraph node - 不负责 Graph routing - 不负责遍历整个 chunk - 只处理 chunk 中“一个 node 的返回结果”。
 
async def _handle_node_update(
    self,
    node_name,
    node_data,
    runtime,
    research_id,
    model,
):
    # 先 生成这个节点最基本的 SSE。
    # 比如 clarify_with_user -> CLARIFICATION。
    event = await self._create_node_event(
        node_name,
        node_data,
        runtime,
        research_id,
        model,
    )

    if event:
        yield event

        # 有些节点返回的内容里带 source。
        # 如果有,就顺手再发一个 sources_found 事件: url, link...
        source_event = self._create_sources_event(
            event,
            node_name,
            research_id,
            model,
        )

        if source_event:
            yield source_event

    # 有些节点比较特殊,一个普通 SSE 不够。
    # 比如 research_supervisor,
    # 前端还想看到 planning、thinking、analysis 这些细节。
    # 这些额外事件统一放这里处理。
    async for special_event in self._create_special_node_events(
        node_name,
        node_data,
        runtime,
        research_id,
        model,
    ):
        yield special_event

Node Data 的 format 大概长这个样子。

node_data = {
    "research_brief": "研究长期恋爱关系……",

    "supervisor_messages": [
        AIMessage(
            content="...",
            tool_calls=[
                {
                    "name": "think_tool",
                    "args": {
                        "reflection": "我应该先研究依恋关系……"
                    }
                }
            ]
        )
    ],

    "notes": [
        "研究发现安全型依恋与满意度相关……"
    ],

    "raw_notes": [
        "https://example.com/paper..."
    ]
}

 

【1】

async def _create_node_event(
    self,
    node_name,
    node_data,
    runtime,
    research_id,
    model,
):
    """
    把一个 Graph 节点的返回结果,翻译成最基本的前端进度事件。

    比如:
        clarify_with_user
            ↓
        CLARIFICATION

        write_research_brief
            ↓
        RESEARCH_BRIEF

    这里只做“基础翻译”。
    Supervisor 那些 planning / thinking 等额外信息,不在这里处理。
    """

    # 现有项目已经有这套 node -> SSE 的转换逻辑,
    # 所以这里不用重新写一遍,直接复用。
    event = await self._process_workflow_node(
        node_name,
        node_data,
        research_id,
        model,
        runtime.chunk_count,
    )

    if not event:
        return None

    # 记住目前前端已经走到哪个阶段。
    # 后面的 heartbeat、timeout 等事件会继续使用这个 stage。
    if event.stage:
        runtime.current_stage = event.stage

    return event
_create_node_event

self._process_workflow_node 有必要详解。

【2】

def _create_sources_event(
    self,
    event,
    node_name,
    research_id,
    model,
):
    """
    看看刚生成的前端消息里有没有资料来源。

    比如里面出现网页 URL:
        https://example.com/article

    如果找到了,就再生成一个 sources_found 事件,
    让前端知道“这一步发现了哪些资料来源”。

    没找到就返回 None。
    """

    if not event.content:
        return None

    sources = self._extract_sources_from_text(
        event.content
    )

    if not sources:
        return None

    return StreamingEvent(
        type="sources_found",
        stage=event.stage,
        content=f"📎 Found {len(sources)} sources",
        timestamp=datetime.utcnow().isoformat(),
        research_id=research_id,
        model=model,
        metadata={
            "sources": sources,
            "node_name": node_name,
        },
    )

 【3】

async def _create_special_node_events(
    self,
    node_name,
    node_data,
    runtime,
    research_id,
    model,
):
    """
    有些节点返回的信息特别多,
    一条普通的进度消息装不下。

    目前主要就是 research_supervisor。
    所以如果碰到 Supervisor,
    就把它里面的 planning、thinking 等信息再拆出来发给前端。
    """

    # 普通节点没有额外内容需要展开。
    if node_name != "research_supervisor":
        return

    if not node_data:
        return

    # Supervisor 的详细处理原项目已经有了,
    # 这里直接复用,不把那些细节塞回主流程。
    async for event in self._process_research_supervisor_data(
        node_data,
        research_id,
        model,
        runtime.chunk_count,
    ):
        yield event
_create_special_node_events

 

posted @ 2026-09-01 12:48  郝壹贰叁  阅读(13)  评论(0)    收藏  举报