[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 返回的是: node_name + node_data
但前端需要的不是 LangGraph 原始数据,而是 SSE event。
而且:
一个 Graph node update
不一定只产生一个前端 event。
例如 research_supervisor 可能产生:
- 标准 stage_update
- sources_found
- 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
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

浙公网安备 33010602011771号