DeepResearchSystem 0x01:Agent 基础

前言

上一篇 主要讲了下项目背景和整体构思。这一章废话少说,撸起袖子就是干。

.env

和以往一样,我们项目涉及的 LLM 相关 api_key 等信息都不会硬编码到代码中。而是放在环境文件中。

Python 复制代码
# LLM API Configuration
LLM_APP_KEY = "你的LLM API Key"
LLM_BASE_URL = "你的LLM服务URL"

# MCP Tool Configuration
MCP_APP_ID = "你的MCP工具ID"

# LangSmith Configuration (Optional)
# LANGSMITH_API_KEY = "你的LangSmith API"

# Available Models Configuration (JSON format)
# Format: [{"model_id":"","display_name":"","icon":"Zap or Cpu","icon_color":"tailwind_color_class"}]
AVAILABLE_MODELS=[{"model_id":"qwen3.6-flash","display_name":"Qwen-Flash","icon":"Zap","icon_color":"yellow-400"},{"model_id":"qwen3.6-plus","display_name":"Qwen-Plus","icon":"Zap","icon_color":"orange-400"},{"model_id":"qwen3.7-max","display_name":"Qwen-Max","icon":"Cpu","icon_color":"purple-400"}]

# Web Search Rate Limiting Configuration
WEB_SEARCH_MAX_QPS = 12

# Research Configuration
NUMBER_OF_INITIAL_QUERIES = 2

MAX_RESEARCH_LOOPS = 2

LLM

该模块主要封装客户端与 LLM 的通讯。其实很简单,我们在过往各种 demo 中已经在熟悉不过了。

ModelConfig

定义模型相关配置,在 LLM 调用中唯一确认使用的是哪家的哪个 LLM。

Python 复制代码
class ModelConfig(BaseModel) :
    """LLM模型配置"""
    id : str = Field(..., description="模型ID")
    name : str = Field(..., description="模型名称")
    icon :str = Field(default="Zap", description="图标类型(Zap/Cpu)")
    icon_color : str = Field(default="yellow-400", description="图标颜色")

加载可用模型

前面说了涉及 LLM 的 api_key存储在环境文件中。这里就需要从环境变量加载可用模型。这里一定要注意兜底模型的配置,的那个环境变量未配置时,代码不能因此而 block。

Python 复制代码
def load_available_models_from_env() :
    """从环境变量加载可用模型"""
    model_json = os.getenv(AVAILABLE_MODELS)

    if not model_json :
        # 兜底模型
        return [
            ModelConfig(id="qwen3.6-flash", name="Qwen-Flash", icon="Zap", icon_color="yellow-400"),
            ModelConfig(id="qwen3.6-plus", name="Qwen-Plus", icon="Zap", icon_color="green-400"),
            ModelConfig(id="qwen3.7-max", name="Qwen-Max", icon="Cpu", icon_color="blue-400")
        ]

    try:
        models_data = json.loads(model_json)
        return [ModelConfig(**model) for model in models_data]
    except Exception as e :
        print(f"警告: 解析AVAILABLE_MODELS失败,使用默认模型列表。错误: {e}")
        return [
            ModelConfig(id="qwen3.6-flash", name="Qwen-Flash", icon="Zap", icon_color="yellow-400"),
            ModelConfig(id="qwen3.6-plus", name="Qwen-Plus", icon="Zap", icon_color="green-400"),
            ModelConfig(id="qwen3.7-max", name="Qwen-Max", icon="Cpu", icon_color="blue-400")
        ]

默认模型

Python 复制代码
def get_default_model_id() :
    """获取默认模型ID(模型列表的最后一项)"""
    models = load_available_models_from_env()
    if models :
        return models[-1].id
    return "qwen3.7-max"  # 兜底默认值

Agent

Agent 负责承接上层具体的业务,封装 LLM 并将提示词传给 LLM。同时也负责对 LLM 返回的数据进行处理。对 LLM 核心调用在于 step方法传入提示词和其他关键信息。

Python 复制代码
class Agent :
    step_prompt = """{prompt}"""
    def __init__(self, model_id=get_default_model_id()) :
        self.llm = OpenAICompatibleLLM(model_id=model_id)

    def __call(self, prompt) :
        response = self.llm.generate_response(prompt)
        return response

    def set_step_prompt(self, prompt):
        self.step_prompt = prompt

    def step(self, **kwargs):
        step_prompt = self.prompt_format(self.step_prompt, **kwargs)
        response = ""
        for _ in range(3) :
            try:
                response = self(step_prompt)
                response = self.post_process(response)
                break
            except Exception as e :
                logger.error(f"大模型调用错误:{e}\n{traceback.format_exc()}")
                continue

            return response

    def post_process(self, response):
        return response

Prompt 模板渲染

注意,这里主要完成 kwargs关键参数到 prompt {xxx}占位符的高效替换。

Python 复制代码
def prompt_format(self, prompt, **kwargs) :
    """高效进行 Prompt 模板渲染的基础方式"""
    # 深拷贝原prompt,避免污染外部该变量
    prompt_ = copy.deepcopy(prompt)
    # kwargs:关键参数包,eg:input="Hello", style="Formal";遍历参数包读值
    for k in  kwargs.keys() :
        # 构造占位符,并将占位符(key)对应的值修改为参数包传过来的值,完成prompt模板的渲染
        rep = "{" + k + "}"
        prompt_ = prompt_.replace(rep, str(kwargs[k]))

    return prompt_

JsonAgent

Agent 子类。主要负责将 LLM 返回数据转换为 JSON 根式。核心代码在post_process方法。

Python 复制代码
class JsonAgent(Agent):
    """将LLM返回数据转换为JSON根式"""
    def __init__(self, model_id=get_default_model_id(), keys=None):
        super().__init__(model_id)
        self.keys = keys

    def post_process(self, response):
        """self.keys参数转换为Pydantic模型类"""
        result = json.loads(jsonUtils.extract_pattern(response, pattern="json"))
        if not self.keys:
            return result
        # **result:字典解包,将result "炸开"成 key=value 的形式
        # self.keys:Pydantic 模型类工厂函数,相当于使用result进行构造;Pydantic自动做字段验证和类型转换
        return self.keys(**result)

限流器

什么是限流器? 主流的 LLM 对外部请求都会做限流限制。比如 1s 内连续 N 次请求会触发 429 错误。所以我们在请求 LLM 时,要做一个限流器用于控制自己发起的 LLM 请求,以减少触发 429 错误的概率。我们目前实现的 RateLimiter 基于令牌桶算法。

令牌桶算法是网络流量整形(Traffic Shaping)和速率限制(Rate Limiting)中最经典的算法之一。

  • 1. 有一个桶 :系统以恒定的速率(如你的 max_qps=15)向桶里放入"令牌"。

  • 2. 令牌满了就溢出:桶有最大容量(Burst Size)。如果桶满了,新来的令牌会被丢弃。

  • 3. 请求拿令牌:每当一个请求进来,必须从桶里拿走一个令牌。

  • 4. 没令牌就等待/拒绝:如果桶空了,请求就必须等待,直到有新的令牌生成,或者直接被拒绝。

  • 本质:允许短期突发的平均速率限制器。长期速率趋近于 rate = 令牌生成速率,但瞬间可以处理 burst = 桶容量 个请求。

一个🌰:门一直开着(非阻塞),但是有个保安(令牌桶算法)在发准入手环(令牌)。手环以固定速率(每66ms一个)生成,放在一个盒子里(桶)。

  • 果盒子里有10个手环(桶容量=10),来了10个人,瞬间全部拿走手环,同时进门

  • 第11个人来的时候,盒子空了,他必须等到下一个手环生成(66ms后)才能拿着手环进去。

Python 复制代码
class TokenBucketRateLimiter:
    """
    基于标准令牌桶算法的速率限制器
    支持突发流量,用于控制API请求频率,避免触发429错误
    """

    def __init__(self, max_qps: float = 15.0, burst_capacity: float | None = None):
        """
        初始化令牌桶速率限制器
        :param max_qps: 令牌生成速率(每秒请求数)
        :param burst_capacity: 桶容量(最大突发请求数),默认等于max_qps,即允许1秒内的全部突发
        """
        self.max_qps = max_qps
        self.burst_capacity = burst_capacity if burst_capacity is not None else max_qps
        # 初始桶是满的,支持启动阶段突发
        self.current_tokens = float(self.burst_capacity)
        # 用单调时间,避免系统时钟调整导致的限流失效
        self.last_refill_time = time.monotonic()
        self.lock = asyncio.Lock()
        logger.info(
            f"令牌桶速率限制器初始化:QPS={max_qps}, "
            f"桶容量={self.burst_capacity}, "
            f"单令牌生成间隔={1.0 / max_qps:.3f}秒"
        )

    async def acquire(self) -> float:
        """
        阻塞式获取请求许可,若令牌不足则等待至有足够令牌
        :return: float 实际等待时间(秒)
        """
        wait_time = 0.0
        async with self.lock:
            now = time.monotonic()
            # 1. 惰性填充令牌:计算从上次填充到现在的新增令牌数
            # 等待的时间
            elapsed = now - self.last_refill_time
            # 等待时间新增的令牌数
            new_tokens = elapsed * self.max_qps
            # 当前令牌数更新
            self.current_tokens = min(self.burst_capacity, self.current_tokens + new_tokens)
            # 上次更新时间
            self.last_refill_time = now

            # 2. 检查是否有足够令牌
            if self.current_tokens >= 1.0:
                # 令牌足够一次请求,放行当前请求,令牌数减1
                self.current_tokens -= 1.0
            else:
                # 3. 令牌不够一次请求。计算需要等待的时间:补上缺口所需的时长
                # 还需要多少令牌才够一次请求
                deficit = 1.0 - self.current_tokens
                # 达到这些令牌数需要等待的时间
                wait_time = deficit / self.max_qps
                # 提前更新填充时间为未来时间,避免重复计算;这个时间后至少可满足一次请求
                self.last_refill_time = now + wait_time
                self.current_tokens = 0.0  # 等待后刚好消耗1个令牌,剩余0

        # 关键:锁外睡眠,避免长时间占用锁影响并发
        if wait_time > 0:
            logger.debug(f"速率限制,需要等待{wait_time:.3f}秒")
            await asyncio.sleep(wait_time)  # 使用 await
        return wait_time

    def try_acquire(self) -> bool:
        """
        非阻塞式获取请求许可,若令牌不足立即返回False
        :return: bool 是否成功获取许可
        """
        with self.lock:
            now = time.monotonic()
            # 惰性填充令牌
            elapsed = now - self.last_refill_time
            new_tokens = elapsed * self.max_qps
            self.current_tokens = min(self.burst_capacity, self.current_tokens + new_tokens)
            self.last_refill_time = now

            if self.current_tokens >= 1.0:
                self.current_tokens -= 1.0
                return True
            return False

    def get_remaining_tokens(self) -> float:
        """返回当前剩余令牌数,仅用于监控调试"""
        with self.lock:
            return self.current_tokens

重试机制

因为有限流就会有重试,但是不能立刻盲目地重试。针对不同的错误要采取不同的重试策略。一言以蔽之就是:「错误分类→退避决策→自适应调整→熔断兜底」

  • 错误分级过滤

    • 永久错误(Permanent):API Key无效、内容合规拦截、参数非法 → 直接终止,禁止重试
    • 瞬时错误(Transient):429(限流)、500/502/503(服务抖动)、网络超时 → 允许重试
  • 差异化退避策略

    • 优先读取LLM响应头的Retry-After(服务商明确告知的恢复时间,准确率100%),无则返回时采用 「指数退避+随机抖动」
    • 区分限流类型:
      • RPM(请求数)超限退避系数设为5 * 2^attempt
      • TPM(Token数)超限先降级请求(减少max_tokens/拆分请求)再退避。
  • 自适应限流联动 :连续2次触发429,动态下调本地令牌桶的 max_qps(如降至原值的50%),直到连续10次请求成功再恢复,从源头减少重试概率。

  • 成本熔断:累计重试 Token 成本超过单次请求阈值(如原请求的2倍)时直接终止,避免无效烧钱

上面提到了 「指数退避+随机抖动」,那么具体是指什么呢?

  • 指数退避:一种逐步拉长重试间隔的策略,公式为:delay = base_delay * (2 ^ attempt)
    • base_delay:基础等待时间(LLM场景推荐5s起步,远大于普通API的1s,因为LLM服务商恢复周期更长)
    • attempt:重试次数(第1次重试attempt=0,第2次=1,以此类推)
    • 效果:第1次等5s,第2次等10s,第3次等20s,避免短时间内高频重试冲击服务端。
  • 随机抖动:防止指数退避引起惊群效应 。在计算结果上叠加随机因子,把重试时间打散,公式为:delay = base_delay * (2 ^ attempt) * random.uniform(1-jitter, 1+jitter)
    • 惊群效应:100个客户端同时触发429,都会在第5s、10s、20s整点重试,再次集中打爆服务端,陷入「限流→重试→更严厉限流」的死循环。
    • jitter:抖动系数(LLM场景推荐0.3~0.5,即±30%~50%的波动范围)
    • 效果:原本统一的5s等待,会变成3.5s~6.5s之间的随机值,错开所有客户端的重试时间。

我们这里的代码并没有实现复杂的重试机制,只是简单的线性退避。

WebSearchAgent

MCPAgent 子类。主要实现和 WebSearch MCP 的通讯。

Python 复制代码
class WebSearchAgent(MCPAgent):
    """WebSearch MCP"""
    async def astep(self, prompt, **kwargs):
        try:
            step_prompt = self.step_prompt.format(prompt=prompt)
        except Exception as e:
            step_prompt = self.step_prompt

        api_key = os.getenv("LLM_API_KEY")
        app_id = os.getenv("MCP_APP_ID")

        # 获取速率限制器; 防止触发限流
        rate_limiter = get_web_search_rate_limiter()
        for attempt in range(3):
            try:
                # 在发送请求前进行速率限制检查
                wait_time = await rate_limiter.acquire()
                if wait_time > 0 :
                    logger.debug(f"速率限制等待:{wait_time:.3f}秒")

                # 获取到令牌后,再切换到线程池执行阻塞的 HTTP 请求
                # 此时当前协程让出 CPU,但 rate_limiter 的锁已释放,其他协程可申请令牌
                response = await asyncio.to_thread(
                    Application.call,
                    api_key=api_key,
                    app_id=app_id,
                    prompt=step_prompt,
                    biz_params=kwargs,
                    )
                response = self.extract_pages_from_mcp_response(response, None)
                return response

            except Exception as e:
                error_msg = str(e)
                logger.error(f"Web搜错错误(尝试{attempt + 1} / 3):{e}\n{traceback.format_exc()}")

                # 我们本身在请求时已经有了 rate_limiter 为什么还需要判断 MCP_ERROR_RATE_LIMIT 错误
                # 自定义 rate_limiter 只是能减少触发限流的可能;但是平台(阿里等)内部的限流机制我们并不清楚,所以采取双保险机制
                if "429" in error_msg :
                    # 递增等待时间:5秒、10秒、15秒
                    wait_time = 5 * (attempt + 1)
                    logger.warning(f"检测到 429 错误,等待 {wait_time} 秒后重试...")
                    time.sleep(wait_time)
                # 非429错误,短暂等待后重试
                elif attempt < 2:
                    time.sleep(2)
                continue
        return None

多路探测防御性解析

如果我们使用 MCP 使用得多了,就可能会发现 MCP 返回的数据结构不同的厂商可能有所差异。这就导致数据解析中也不可能是一成不变的,为了解决这种工具调用返回结构的"不确定性"问题,这里写了一个函数用于多路探测防御性解析返回数据。

  • 使用预定义的 JSONPath 列表递归解析
  • 使用抽象归一的解析函数代替分散的if/else代码,几种统一
  • 扩展性友好,后续新增路径只需要更新预定义的 JSONPath 列表即可
Python 复制代码
def extract_pages_from_mcp_response(self, response, config=None):
    """从MCP WebSearch响应中提取pages数据(重构版)
    Args:
    response: MCP响应对象
    config: 解析路径配置,默认使用预定义的JSONPath列表
    """
    if response is None:
        raise Exception("Web搜索结果不正确")
    if not response.status_code == 200:
        raise Exception(f"Web搜索异常: {response.status_code}")
    # 定义兜底的解析scheme 优先级从高到低
    # 多路探测防御性解析,解决MCP等工具调用返回结构的"不确定性"问题
    DEFAULT_PARSE_CONFIG = {
        "paths": [
            "$.pages",                  # 直接在第一层
            "$.data.pages",             # data路径
            "$.result.content[0].text",  # result.content路径
            "$.choices[0].message.content",
            # 未来新增路径只需加在这里,无需改逻辑
        ]
    }

    parse_config = config if config else DEFAULT_PARSE_CONFIG

    try:
        # 假设 response.output.text 是原始的JSON字符串
        first_level = json.loads(response.output.text)
    except json.JSONDecodeError as e:
        logger.error(f"JSON解析失败: {e}, 原始响应: {response.output.text[:500]}")
        raise Exception(f"Web搜索结果JSON解析失败: {str(e)}")

    pages = None
    matched_path = None

    # 循环遍历JSONPath进行匹配
    for path_str in parse_config["paths"]:
        try:
            # 编译表达式(生产环境建议缓存此对象)
            jsonpath_expr = jsonpath_parser.parse(path_str)
            matches = [match.value for match in jsonpath_expr.find(first_level)]
            if matches:
                # 取第一个匹配项
                candidate = matches[0]

                # 特殊处理:如果路径指向的是字符串(如 content[0].text),则再次解析
                if isinstance(candidate, str):
                    try:
                        candidate = json.loads(candidate)
                        # 二次解析后,如果里面还有pages,需要再次查找
                        # 简单处理:如果二次解析后是dict且包含pages,则替换
                        if isinstance(candidate, dict) and "pages" in candidate:
                            pages = candidate["pages"]
                            matched_path = path_str + " -> inner.pages"
                            break
                        # 如果二次解析后直接是list,也可能是我们要的数据
                        elif isinstance(candidate, list):
                            pages = candidate
                            matched_path = path_str + " -> inner.list"
                            break
                    except json.JSONDecodeError:
                        # 如果不是JSON字符串,忽略该路径
                        continue

                # 如果直接找到了列表
                elif isinstance(candidate, list):
                    pages = candidate
                    matched_path = path_str
                    break

        except JSONPathError as e:
            logger.warning(f"JSONPath语法错误或匹配失败: {path_str}, Error: {e}")
            continue
        except (KeyError, IndexError, TypeError) as e:
            # 路径存在但索引越界等情况,继续尝试下一条路径
            continue

    # 后置校验与错误处理
    if pages is None:
        logger.error(
            f"无法从响应中提取pages数据。尝试的路径: {parse_config['paths']}。"
            f"响应结构: {json.dumps(first_level, ensure_ascii=False)[:500]}"
        )
        raise Exception("无法从Web搜索结果中提取页面数据")

    if not isinstance(pages, list):
        logger.error(f"pages不是列表类型: {type(pages)}, 匹配路径: {matched_path}")
        raise Exception("Web搜索结果格式错误")

    logger.info(f"成功从路径 '{matched_path}' 提取到 {len(pages)} 条pages数据")

    # 数据清洗 目标-"垃圾进,精品出",防止-"Garbage in,Garbage out"
    processed_pages = []
    for page in pages:
        if isinstance(page, dict):
            processed_pages.append({
                "snippet": page.get("snippet", ""),
                "title": page.get("title", ""),
                "url": page.get("url", "")
            })

    return processed_pages

WebSearch MCP

在以上代码我们也发现会用到 MCP_APP_ID 等信息。这是哪里来的呢?这个 id 来源于我们使用的哪个厂商的 MCP 工具。以我们项目为🌰,WebSearch MCP 使用的是阿里百炼平台的网络搜索 MCP。

展望

至此,我们完成了Agent 基础的相关代码。包括 LLM 封装,Prompt 渲染,Agent 类封装等。后面有机会,我们继续用 Graph 实现主流程。

相关推荐
sweet丶1 小时前
移动端轻量级AI Agent自动化UI验证:sim-use
ai编程
东小西2 小时前
第7篇:《工具的USB接口:我把Function Calling升级成了MCP》
openai·ai编程
一只小bit3 小时前
LangGraph 子图使用和房源搜索Agent综合案例实现
机器学习·langchain·llm·langgraph
腻害兔3 小时前
【若依项目-产品经理视角】RuoYi-Vue-Pro 源码拆解:CRM 客户关系模块深度解析——从线索到回款,一套完整的 B2B 销售闭环是怎么搭的?
java·前端·javascript·vue.js·产品经理·ai编程
大数据点灯人3 小时前
【AI编程】Vibe Coding 模型选型:越贵越好吗?效果/速度/成本平衡指南
编程·ai编程·claude·codex·vibe coding
oort1233 小时前
吃上了自家的细糠,还挺丝滑,用起来手感还行,OortCloud发布新版AI编程平台,下载 OortCodex,Token多,免费薅
大数据·开发语言·人工智能·ai编程
小虎AI生活4 小时前
WorkBuddy 自动化实战,TTS 配音从安装到跑通
ai编程
艾斯特_5 小时前
从模型调用到可用聊天应用:会话状态与消息协议
python·ai·aigc
bloglin999995 小时前
langchain 和 langgraph 和 react
javascript·react.js·langchain