Crawl4AI 支持外接豆包(DouBao)LLM大模型

Crawl4AI 支持外接豆包(DouBao)LLM大模型

Crawl4AI 是一个强大的 AI 驱动网络爬虫工具,能够简化从网页中提取结构化数据的过程。根据官方文档,当前版本(v0.5.0.post8)并不原生支持豆包(Doubao)模型,但是火山引擎本身就支持OpenAI SDK以及OpenAI调用格式,因此从技术通用理论上来讲,是能够被Crawl4AI所调用的。

然而,当我尝试通过 litellm 集成 doubao-Pro-256k-241115 模型时,遇到了 'str' object has no attribute 'choices' 错误。

本文将分析错误原因,分享调试过程,并提供一个通用的解决方案,通过修改 Crawl4AI 源码使其支持豆包模型,同时兼容原始代码和可能的修改版本。如果你在 Crawl4AI 中集成非原生支持的 LLM,遇到类似问题,这篇指南将为你提供清晰的解决思路。


问题背景:一个棘手的错误

我使用以下脚本尝试通过 Crawl4AI 和豆包模型爬取网页,提取结构化数据(URL、标题和正文):

import litellm
from crawl4ai import AsyncWebCrawler, LLMConfig, CrawlerRunConfig, LLMExtractionStrategy
import asyncio
import json
from dataset.crawl4ai import Crawl4aiCrawler

async def test_crawl():
    crawler = Crawl4aiCrawler()
    llm_config = LLMConfig(
        provider="openai/doubao-pro-256k-241115",
        api_token="fake-api-token-1234567890",
        base_url="https://ark.cn-beijing.volces.com/api/v3",
    )
    run_config = CrawlerRunConfig(
        word_count_threshold=1,
        extraction_strategy=LLMExtractionStrategy(
            llm_config=llm_config,
            schema={
                "type": "object",
                "properties": {
                    "url": {"type": "string"},
                    "title": {"type": "string"},
                    "text": {"type": "string"}
                }
            },
            instruction="\n从网页内容中提取页面标题和主要文本内容,以 JSON 格式返回,包含 'url'、'title' 和 'text' 字段。",
        ),
        cache_mode="BYPASS",
    )
    async with AsyncWebCrawler() as crawler:
        result = await crawler.arun(url="https://www.baidu.com", config=run_config)
        print("Success:", result.success)
        print("Extracted Content:", json.dumps(result.extracted_content, ensure_ascii=False, indent=2))

if __name__ == "__main__":
    asyncio.run(test_crawl())

运行时,抛出了以下错误:

'str' object has no attribute 'choices'

由于 Crawl4AI 不原生支持豆包模型,我怀疑问题出在 litellm 与 Crawl4AI 的响应处理不兼容。


调试过程:定位错误根源

1. 验证豆包模型响应

我先单独测试了 litellm.completion 调用,确认豆包模型的输出:

import litellm
response = litellm.completion(
    model="openai/doubao-pro-256k-241115",
    messages=[{"role": "user", "content": "你好,世界!"}],
    api_key="fake-api-token-1234567890",
    base_url="https://fake-api-endpoint.com/v3"
)
print(response)

输出显示 response 是一个 litellm.ModelResponse 对象,包含 choices 字段:

{
  "id": "fake-response-id-1234567890",
  "model": "doubao-pro-256k-241115",
  "choices": [
    {
      "finish_reason": "stop",
      "message": {
        "content": "你好呀!很高兴看到你向世界发出这样热情的问候呢,希望你每天都开心哦。",
        "role": "assistant"
      }
    }
  ],
  "usage": {
    "completion_tokens": 23,
    "prompt_tokens": 13,
    "total_tokens": 36
  }
}

这表明豆包模型通过 litellm 的调用正常,问题出在 Crawl4AI 的响应处理逻辑上。

2. 检查 Crawl4AI 源码

通过 pip show crawl4ai 找到安装目录(通常在 site-packages/crawl4ai),定位到 extraction_strategy.py 中的 LLMExtractionStrategy 类。extract 方法是处理 LLM 响应的核心逻辑。

原始代码的关键部分如下:

response = perform_completion_with_backoff(...)
response = response.choices[0].message.content  # 错误:将 response 覆盖为字符串
# ...
parsed, unparsed = split_and_parse_json_objects(response.choices[0].message.content)  # 错误:尝试访问字符串的 choices

问题很明显

  • response 被覆盖为 response.choices[0].message.content(一个字符串)。
  • 后续错误处理逻辑错误地假设 response 仍是 ModelResponse 对象,试图访问 response.choices,导致报错。

我注意到修改后的代码已修复此问题,正确处理了 response:

response = perform_completion_with_backoff(...)
content = response.choices[0].message.content  # 正确:使用 content 存储字符串
# ...
parsed, unparsed = split_and_parse_json_objects(content)  # 使用 content 解析

为确保解决方案兼容两种版本,我设计了一个通用的修复方案。

3. 确认问题范围

  • Crawl4aiCrawler(在 dataset/crawl4ai.py 中)仅包装了 AsyncWebCrawler,不直接处理 LLM 响应。
  • 错误源于 LLMExtractionStrategy.extract 方法中对 response 的错误处理。
  • Crawl4AI 不原生支持豆包模型,需通过修改源码适配 litellm 的响应格式。

解决方案:通用修复方案支持豆包模型

为让 Crawl4AI 支持豆包模型并解决错误,我设计了一个通用的修复方案,兼容原始代码(错误覆盖 response)和修改后的代码(正确使用 content)。以下是详细步骤:

步骤 1:备份源码

在修改前,备份原始文件以防需要恢复:

cp /path/to/site-packages/crawl4ai/extraction_strategy.py /path/to/site-packages/crawl4ai/extraction_strategy.py.bak

步骤 2:修复 extract 方法

修改 LLMExtractionStrategy.extract 方法,确保 response 始终是 ModelResponse 对象,并在解析时使用单独的 content 变量。以下是通用修复后的代码,兼容两种版本:

def extract(self, url: str, ix: int, html: str) -> List[Dict[str, Any]]:
    """
    使用 LLM 从给定 HTML 中提取有意义的块或片段。

    工作原理:
    1. 构造包含变量的提示。
    2. 使用提示调用 LLM。
    3. 解析响应并提取块或片段。

    参数:
        url: 网页的 URL。
        ix: 块的索引。
        html: 网页的 HTML 内容。

    返回:
        提取的块或片段列表。
    """
    if self.verbose:
        print(f"[LOG] Call LLM for {url} - block index: {ix}")

    variable_values = {
        "URL": url,
        "HTML": escape_json_string(sanitize_html(html)),
    }

    prompt_with_variables = PROMPT_EXTRACT_BLOCKS
    if self.instruction:
        variable_values["REQUEST"] = self.instruction
        prompt_with_variables = PROMPT_EXTRACT_BLOCKS_WITH_INSTRUCTION

    if self.extract_type == "schema" and self.schema:
        variable_values["SCHEMA"] = json.dumps(self.schema, indent=2)
        prompt_with_variables = PROMPT_EXTRACT_SCHEMA_WITH_INSTRUCTION

    if self.extract_type == "schema" and not self.schema:
        prompt_with_variables = PROMPT_EXTRACT_INFERRED_SCHEMA

    for variable in variable_values:
        prompt_with_variables = prompt_with_variables.replace(
            "{" + variable + "}", variable_values[variable]
        )

    try:
        response = perform_completion_with_backoff(
            self.llm_config.provider,
            prompt_with_variables,
            self.llm_config.api_token,
            base_url=self.llm_config.base_url,
            json_response=self.force_json_response,
            extra_args=self.extra_args,
        )
        # 跟踪使用量
        usage = TokenUsage(
            completion_tokens=response.usage.completion_tokens,
            prompt_tokens=response.usage.prompt_tokens,
            total_tokens=response.usage.total_tokens,
            completion_tokens_details=response.usage.completion_tokens_details.__dict__
            if response.usage.completion_tokens_details
            else {},
            prompt_tokens_details=response.usage.prompt_tokens_details.__dict__
            if response.usage.prompt_tokens_details
            else {},
        )
        self.usages.append(usage)

        # 更新总数
        self.total_usage.completion_tokens += usage.completion_tokens
        self.total_usage.prompt_tokens += usage.prompt_tokens
        self.total_usage.total_tokens += usage.total_tokens

        try:
            # 提取 content,而不是覆盖 response
            content = response.choices[0].message.content
            blocks = None

            if self.force_json_response:
                blocks = json.loads(content)
                if isinstance(blocks, dict):
                    if len(blocks) == 1 and isinstance(list(blocks.values())[0], list):
                        blocks = list(blocks.values())[0]
                    else:
                        blocks = [blocks]
                elif isinstance(blocks, list):
                    blocks = blocks
            else:
                blocks = extract_xml_data(["blocks"], content)["blocks"]
                blocks = json.loads(blocks)

            for block in blocks:
                block["error"] = False
                block["index"] = ix

            if self.verbose:
                print(f"[LOG] Extracted {len(blocks)} blocks from URL: {url}, block index: {ix}")
            return blocks

        except Exception as e:
            if self.verbose:
                print(f"[LOG] Error parsing LLM response: {e}")
            parsed, unparsed = split_and_parse_json_objects(content)
            blocks = parsed
            if unparsed:
                blocks.append(
                    {
                        "index": ix,
                        "error": True,
                        "tags": ["error"],
                        "content": unparsed,
                    }
                )
            return blocks

    except Exception as e:
        if self.verbose:
            print(f"[LOG] LLM extraction error: {e}")
        return [
            {
                "index": ix,
                "error": True,
                "tags": ["error"],
                "content": str(e),
            }
        ]

关键改动

  • 通用处理 response:使用 content = response.choices[0].message.content 提取字符串,避免覆盖 response。
  • 兼容两种版本:如果用户已应用修改后的代码(正确使用 content),此修复不会破坏现有逻辑。如果用户使用原始代码,修复会覆盖错误逻辑,正确处理豆包模型的响应。
  • 适配豆包模型:假设豆包模型通过 litellm 返回标准的 ModelResponse,并根据指令生成 JSON 格式输出。
  • 增强日志:添加详细日志,方便调试响应内容和错误。
  • 错误处理:在 except 块中,使用 content 解析 JSON,避免访问字符串的 choices。确保返回的 blocks 包含正确的 index 和 error 信息。

步骤 3:测试修复

保存修改后的 extraction_strategy.py 后,使用以下脚本测试(URL 改为 https://example.com 以简化验证):

import litellm
from crawl4ai import AsyncWebCrawler, LLMConfig, CrawlerRunConfig, LLMExtractionStrategy
import asyncio
import json
from dataset.crawl4ai import Crawl4aiCrawler

async def test_crawl():
    crawler = Crawl4aiCrawler()
    llm_config = LLMConfig(
        provider="openai/doubao-pro-256k-241115",
        api_token="fake-api-token-1234567890",
        base_url="https://fake-api-endpoint.com/v3",
    )
    run_config = CrawlerRunConfig(
        word_count_threshold=1,
        extraction_strategy=LLMExtractionStrategy(
            llm_config=llm_config,
            schema={
                "type": "object",
                "properties": {
                    "url": {"type": "string"},
                    "title": {"type": "string"},
                    "text": {"type": "string"}
                }
            },
            instruction="从网页内容中提取页面标题和主要文本内容,以 JSON 格式返回,包含 'url'、'title' 和 'text' 字段。",
        ),
        cache_mode="BYPASS",
    )
    async with AsyncWebCrawler() as crawler:
        result = await crawler.arun(url="https://example.com", config=run_config)
        print("Success:", result.success)
        print("Extracted Content:", json.dumps(result.extracted_content, ensure_ascii=False, indent=2))

if __name__ == "__main__":
    asyncio.run(test_crawl())

预期输出

[LOG] Call LLM for https://example.com - block index: 0
[LOG] Extracted 1 blocks from URL: https://example.com, block index: 0
Success: True
Extracted Content: [
  {
    "url": "https://example.com",
    "title": "Example Domain",
    "text": "This domain is for use in illustrative examples in documents...",
    "index": 0,
    "error": false
  }
]

运行结果没有抛出 'str' object has no attribute 'choices' 错误,提取的数据符合预期,证明修复对两种代码版本均有效。


为什么会出错?

错误的根本原因是 Crawl4AI 不原生支持豆包模型,且原始代码在 LLMExtractionStrategy.extract 中存在逻辑错误:

  • 原始代码问题
    • response = perform_completion_with_backoff(...) 返回 ModelResponse。
    • response = response.choices[0].message.content 将 response 覆盖为字符串。
    • 后续 split_and_parse_json_objects(response.choices[0].message.content) 尝试访问字符串的 choices,触发错误。
  • 修改后代码
    • 已正确使用 content = response.choices[0].message.content,避免覆盖 response。
    • 但为确保兼容性,需统一处理逻辑。
  • 豆包模型适配
    • 豆包通过 litellm 返回标准的 ModelResponse,但 Crawl4AI 未针对非原生支持的模型优化,导致解析不一致。
    • 通过分离 content 和 response 的处理,并适配 litellm 的响应格式,我们解决了问题。

注意事项

  • 检查自定义代码
    • 我的 Crawl4aiCrawler(在 dataset/crawl4ai.py 中)仅包装了 AsyncWebCrawler,未直接处理 LLM 响应。检查你的自定义代码,确保没有类似问题。
  • 清理缓存
    • 修改源码后,Python 可能加载旧字节码。清理缓存可避免问题:

      find /path/to/site-packages/crawl4ai -name "*.pyc" -delete
      
  • 验证 LLM 输出
    • 确保豆包模型返回的数据格式与指令一致。例如:

      response = litellm.completion(
          model="openai/doubao-pro-256k-241115",
          messages=[
              {"role": "system", "content": "返回 JSON 格式,包含 'url'、'title' 和 'text'。"},
              {"role": "user", "content": "URL: https://example.com\n标题: 示例\n内容: 你好"}
          ],
          api_key="fake-api-token-1234567890",
          base_url="https://fake-api-endpoint.com/v3"
      )
      print(response.choices[0].message.content)
      
  • 版本兼容性
    • 我使用的是 crawl4ai-0.5.0.post8。如果问题仍存,尝试更新到最新版本:

      pip install --upgrade crawl4ai
      

总结

通过修改 LLMExtractionStrategy.extract 方法,我成功让 Crawl4AI 支持了豆包模型,并解决了 'str' object has no attribute 'choices' 错误。通用修复方案兼容原始代码(错误覆盖 response)和修改后的代码(正确使用 content),确保了鲁棒性。

希望这篇博客能帮助你在 Crawl4AI 中集成非原生支持的 LLM 时少走弯路。简单的修改源代码能够很有效解决当前问题,总体来说Crawl4AI是非常优秀的框架,但是优秀的框架也需要配合免费的LLM才能让个人开发走的更远。

6 Likes

:tieba_087: 太强了!重新排版好看多了

谢谢嘻嘻

这个爬取效果好吗~ 下次可以体验体验

豆包好用?

问下抓取效果怎么样?

大佬,求代码 :sweat_smile: