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才能让个人开发走的更远。