玩转chromedp

最近有项目需要使用无头浏览器进行后台任务处理。古早以前使用 PhantomJS 进行无头浏览器操作,但现在已经不再维护了。现在推荐使用 Chrome DevTools Protocol (CDP) 来实现无头浏览器的功能。

Chrome DevTools Protocol (CDP)

CDP 是 Chrome 浏览器提供的一个协议,允许开发者通过编程方式控制浏览器的行为。它可以用于调试、性能分析、自动化测试等场景。

CDP 的文档可以在这里找到:Chrome DevTools Protocol

Golang 中的 CDP 实现

作为一个gopher, 综合开发、调试和部署成本,决定继续选用golang chromedp 进行无头浏览器操作。这个项目比六七年前使用涨了快10倍的star, 不得不说这个项目的生命力真的很强。

这个项目符合golang项目一贯的开箱即用风格。示例也非常丰富,因此一般的测试直接找到对应示例进行修改即可。但惟独自己计划项目中需要使用的几个特性要求找不到匹配的实现。

特性要求

  • 安全隔离,需要再远程服务运行浏览器
  • 响应时效,页面打开机操作与本地打开不能有显著差异
  • 成本控制,单台服务器上需要支持多个浏览器实例运行,上下文隔离
  • 业务流程匹配,主要是需要对页面操作支持中断操作和持续执行

代码是不可能自己亲自花时间写的。借用cursor, 花了两分钟生成,两分钟调整代码结构:

package main

import (
"context"
"log"
"os"
"time"

"github.com/chromedp/cdproto/network"
"github.com/chromedp/cdproto/target"
"github.com/chromedp/chromedp"
)

const cookie = "TODO"

const REMOTE_ADDR = "ws://127.0.0.1:9222"

const REMOTE_ALLOC_CTX = true

func main() {

id, err := openBaidu()
if err != nil {
log.Fatalf("打开百度失败: %v", err)
}

search(id, "当前时间")

log.Printf("openBaidu and search success, id: %s", id)

}

func openBaidu() (target.ID, error) {
allocCtx := getAllocCtx()

// 创建一个带取消功能的上下文
ctx, _ := chromedp.NewContext(
// context.Background(),
allocCtx,
// 设置日志级别
chromedp.WithTargetID("20E9DFB7B355658007B89CC47C5BAC3A"),
)

// 截图保存路径
screenshotPath := "1.open_baidu_result.png"

// 定义截图缓冲区
var buf []byte

// 设置全局请求头,包含Cookie
// 这种方式比在每个请求上单独设置更可靠
headers := map[string]interface{}{
"Cookie": cookie,
}

// 执行任务
err := chromedp.Run(ctx,
// 首先设置全局请求头
network.SetExtraHTTPHeaders(headers),

// 导航到百度首页
chromedp.Navigate("https://www.baidu.com"),

// 等待搜索框元素加载完成
chromedp.WaitVisible("#kw", chromedp.ByID),

// 等待一下确保结果完全加载
chromedp.Sleep(1*time.Second),

// 捕获整个页面的屏幕截图
chromedp.FullScreenshot(&buf, 90),
)

if err != nil {
log.Fatalf("执行任务失败: %v", err)
return "", err
}

// 保存截图
if err := os.WriteFile(screenshotPath, buf, 0644); err != nil {
log.Fatalf("保存截图失败: %v", err)
return "", err
}

cdpCtx := chromedp.FromContext(ctx)
return cdpCtx.Target.TargetID, nil
}

func search(id target.ID, query string) {

allocCtx := getAllocCtx()

ctx, _ := chromedp.NewContext(allocCtx, chromedp.WithTargetID(id))

// 定义截图缓冲区
var buf []byte

chromedp.Run(ctx,
chromedp.SendKeys("#kw", query, chromedp.ByID),
chromedp.Click("#su", chromedp.ByID),
chromedp.WaitVisible("#content_left", chromedp.ByID),
// 捕获整个页面的屏幕截图
chromedp.Sleep(1*time.Second),
chromedp.FullScreenshot(&buf, 90),
)

// 保存截图
if err := os.WriteFile("2.search_result.png", buf, 0644); err != nil {
log.Fatalf("保存截图失败: %v", err)
}
log.Printf("保存截图到 %s\n", "search_result.png")
}

func getAllocCtx() context.Context {
if REMOTE_ALLOC_CTX {
allocCtx, _ := chromedp.NewRemoteAllocator(context.Background(), REMOTE_ADDR)
return allocCtx
} else {
opts := append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.Flag("headless", false),
)
allocCtx, _ := chromedp.NewExecAllocator(context.Background(), opts...)
return allocCtx
}
}

简单说明一下:

  • 前3个特性要求
  • 通过 chromedp.NewRemoteAllocator 创建远程浏览器实例,支持多实例隔离
  • 通过 chromedp.WithTargetID 设置浏览器实例 ID,支持tab页面关联,实现中断交互

本来计划要花点时间的任务,在AI加持下分分钟搞定。可以迎接端午安康了!

近期LLM的一些趋势之二——MCP

书接上回,文末提到一个点:

当然也有不变的点:工具的建设依然很重要。只不过,今年需要更多的思考如何将这些基建工具与上述的flowgraph结合起来。

当时没有展开写,而最近随着MCP(Managed Context Protocol)的逐步普及和应用,今天来补一下当时挖的坑。

什么是MCP

参考MCP的官方文档,MCP是一个用于管理和使用上下文的协议,旨在帮助开发者更好地与大型语言模型(LLM)进行交互。它提供了一种结构化的方法来处理上下文信息,使得在与LLM进行对话时,可以更有效地传递和管理信息。

它的工作流程也很简单(LLM以Claude为例):

When you submit a query:

  • The client gets the list of available tools from the server
  • Your query is sent to Claude along with tool descriptions
  • Claude decides which tools (if any) to use
  • The client executes any requested tool calls through the server
  • Results are sent back to Claude
  • Claude provides a natural language response
  • The response is displayed to you

从工作流程来看,是不是感觉跟我们熟悉的Function Calling有些类似?

MCP vs function calling

MCP和Function Calling的区别在于:

  1. 功能范围:MCP不仅仅是一个函数调用的接口,它还包括了上下文管理、工具描述和结果处理等多个方面的功能。而Function Calling主要集中在函数调用本身。
  2. 上下文管理:MCP提供了更为全面的上下文管理功能,可以在多个工具之间共享和传递上下文信息。而Function Calling通常是针对单个函数的调用,缺乏跨函数的上下文管理能力。
  3. 工具描述:MCP允许开发者为每个工具提供详细的描述信息,以便LLM更好地理解如何使用这些工具。而Function Calling通常只提供函数名和参数,缺乏详细的描述信息。
  4. 结果处理:MCP在结果处理上也提供了更多的灵活性,可以根据不同的工具和上下文信息进行定制化的结果处理。而Function Calling通常是固定的结果处理方式,缺乏灵活性。
  5. 适用场景:MCP更适合于复杂的应用场景,尤其是需要多个工具协同工作的场景。而Function Calling更适合于简单的函数调用场景。
  6. 扩展性:MCP的设计考虑了未来的扩展性,可以方便地添加新的工具和功能。而Function Calling在扩展性上相对较弱,添加新功能可能需要较大的改动。
  7. 社区支持:MCP是一个开放的协议,得到了广泛的社区支持和参与。而Function Calling通常是由特定的公司或组织维护,缺乏广泛的社区支持。
  8. 文档和示例:MCP提供了详细的文档和示例,帮助开发者快速上手。而Function Calling的文档和示例相对较少,可能需要开发者自行摸索。
  9. 学习曲线:由于MCP的功能更为全面和复杂,学习曲线相对较陡。而Function Calling相对简单,学习曲线较平缓。
  10. 性能:在性能方面,MCP可能会因为其复杂性而引入一定的性能开销。而Function Calling通常是直接调用函数,性能较高。
  11. 安全性:MCP在设计上考虑了安全性,提供了一些安全机制来保护上下文信息。而Function Calling通常缺乏这样的安全机制,可能存在一定的安全隐患。
  12. 兼容性:MCP是一个开放的协议,可以与多种语言和平台兼容。而Function Calling通常是针对特定语言或平台的,兼容性较差。

此外,可以参考《MCP 与 Function Call 区别》。其中提到一个比喻非常形象:

  • MCP:通用协议层面的标准约定,就像给 LLM 使用的“USB-C规范”。
  • Function Call:特定大模型厂商提供的独特特性,就像某品牌手机的专属充电协议。

如何使用?

回到我们最开始的观点:不变的点:工具的建设依然很重要。对于MCP来说,主要是指MCP Server。作为一个gopher,推荐者以下两个项目:

两个项目都是golang传统的开箱即用。

要让LLM能够把提供工具、资源、prompt驱动起来,需要借助 MCP client 这个桥梁。而目前从官方推荐的Clients来看,MCP client 主要以两种形式出现:①面向普通用户的application应用,如Claude Desktop App;②面向开发者的agent框架库,如mcp-agent。这里推荐fast-agent.

MCP 的意义

MCP的意义在于它为开发者提供了一种结构化的方法来管理和使用上下文信息,使得与LLM的交互更加高效和灵活。通过MCP,开发者可以更好地利用LLM的能力,构建出更为复杂和智能的应用程序。

还是有点抽象,对不对?从过去技术发展历史来看,MCP跟当年的微服务架构有点类似。不同的是,微服务架构下更多的还是工程、业务逻辑驱动数据流和控制流,而MCP则是将LLM作为决策中枢。也就是说,MCP是一个面向LLM的微服务架构。

太阳底下没有新鲜事,以前微服务架构下的经验在MCP架构下会有一定的借鉴意义。另一方面,MCP的出现也意味着我们需要重新审视和设计我们的应用架构,以更好地适应LLM的特点和需求。

近期LLM的一些趋势

不知不觉,LLM已经高歌猛进到了第四年。依然清晰记得两年前使用ChatGPT时候的震撼。那个时候大家认为提出问题是非常重要的能力,这里面也包括prompt优化。很多人的第一次震撼应该大多来自于提出一个好问题后LLM给的超出预期的反馈。

两年过去了,以上提到的依然重要。但是LLM也展示了一些新的魅力。

首先是菜市场大妈都耳熟梦想的DeepSeek R1。用刘飞的总结就是“开城墙”:MIT最友好开源协议,数量级降低训练成本,纯强化学习。分析DeepSeek的文章很多,这里主要是为了引出后面的两个应用。

第一个应用是 OpenAI 的 Operator:

Operator 通过“观察”(通过屏幕截图)和“交互”(使用鼠标和键盘的所有操作)与浏览器进行通信,使其无需定制 API 集成即可在 Web 上执行操作。

简单总结:

  • 感知:文本+截图
  • 交互:确定点击位置
  • 思考决策:GPT-4o 多模态

第二个应用来自字节的UI-TARS

UR-TARS 是该系统系统视觉大模型,执行可以通过 UI-TARS desktop (支撑各种应用程序)以及Midscene.js(仅浏览器)。

UI-TARS is a next-generation native GUI agent model designed to interact seamlessly with graphical user interfaces (GUIs) using human-like perception, reasoning, and action capabilities.

Unlike traditional modular frameworks, UI-TARS integrates all key components—perception, reasoning, grounding, and memory—within a single vision-language model (VLM), enabling end-to-end task automation without predefined workflows or manual rules.

以上三个看似LLM不同方向的进展其实背后都在强调一个概念:推理。从效果看,也就是大众口中的端到端。而推理本质上其实就是让大模型能够的进行自主planning。

在Lillian的范式:agent = LLM + planning + memory + tools中,有过落地经验的就会发现,最重要也最挑战的就是planning。解决planning无论是形式上还是概念是都很多,比如思维链、思维树、反思、SOP等,但其本质上都是为了让LLM去follow某个flowgraph以完成任务。

很多应用场景中使用LLM落地,其实就是围绕如何产出这个flowgraph。比如面向普通用户的可视化画布,面向power user的编码式SOP。而一旦这个flowgraph的产出需要依赖人工参与,那么其可泛化性一定是有限的,再结合成本问题,往往让很多应用场景要么铩羽而归,要么看起来是为了用而用。

而LLM基本都在相同时间专项推理的时候,大家发现flowgraph很多时候可以是LLM内置的。这个时候,LLM的应用场景就会有一个质的飞跃。比如Operator和UI-TARS,都是在这个方向上的尝试。而这是今年区别于两年前很重要的一个跃迁。

因此,今年进行LLM场景落地不妨考虑这两个思路:

  • 对于容错性高的场景,解决思路多想想怎么激发LLM内部的这个flowgraph。
  • 对于容错性低的场景,尝试挖掘LLM的内在flowgraph,然后结合人工的审核再应用。

当然也有不变的点:工具的建设依然很重要。只不过,今年需要更多的思考如何将这些基建工具与上述的flowgraph结合起来。