用 elisp 手搓 gptel tools

从去年开始 gptel 就在测试 tools,最近 0.98 终于正式推出了 tools 支持。不过有点惊讶的是论坛里好像没多少人玩这个,或者就是在等 MCP。其实直接用 elisp 写点 tools 也挺好玩的,而且 得益于 Emacs 强大的现有生态,tool 其实非常好写

下面列举两个比较简单的例子,也欢迎大家分享自己写的纯 elisp 的 tool。

自动操纵 Python REPL

python.el 的 run-python 恰好可以创建一个 与 buffer 关联的 Python REPL。所以很轻松就能实现这样的效果:

(gptel-make-tool
 :name "create_python_repl"
 :function (lambda ()
             (run-python nil t)
             (pop-to-buffer (python-shell-get-buffer)))
 :description "Create a new python repl for this session"
 :args nil
 :category "emacs")

(gptel-make-tool
 :name "send_python_to_repl"
 :function (lambda (code)
             (python-shell-send-string code))
 :args (list '(:name "code"
                     :type string
                     :description "python code to execute"))
 :description "Send some python code to the python repl for this session and execute it"
 :category "emacs")

这个玩法还可以进一步扩展,比如把结果也反馈回去,就可以让 LLM 自动调试/自动完善代码了。

自动搜索和阅读网页

(并且是异步的,可以保留外链)

最近用过一些带联网的 LLM,但是都感觉笨笨的:

  1. 虽然可以搜索,但缓存了老页面,还没法手动强制刷新;
  2. 想手动提供参考信息,只能想办法让 AI 自己搜索到这个页面,而不能直接提供 URL 让 LLM 自己打开阅读。

于是自己定义两个 tool 一个用来读页面,一个用来搜索。这里用 Tavily 搜索 API。读页面提取纯文本,就简单用 shr 搓一个,足够用,甚至还能保留外部链接!(这里就体现出了 Emacs 生态的强大优势)两个配合起来可以实现非常美妙的效果:LLM 可以自动搜索到最新的结果,然后自动打开页面仔细阅读,而不是像很多 LLM app 一样读一下摘要或者缓存的老结果就结束了。

;; 你需要定义一个 tavily-api-key 函数

(gptel-make-tool
 :category "web"
 :name "search"
 :async t
 :function (lambda (cb keyword)
             (tavily-search-async cb keyword))
 :description "Search the Internet"
 :args (list '(:name "keyword"
                     :type string
                     :description "The keyword to search")))

(gptel-make-tool
 :category "web"
 :name "fetch_url_text"
 :async t
 :description "Fetch the plaintext contents from an HTML page specified by its URL"
 :args (list '(:name "url"
                     :type string
                     :description "The url of the web page"))
 :function (lambda (cb url)
             (fetch-url-text-async cb url)))

(defun tavily-search-async (callback query &optional search-depth max-results)
  "Perform a search using the Tavily API and return results as JSON string.
API-KEY is your Tavily API key.
QUERY is the search query string.
Optional SEARCH-DEPTH is either \"basic\" (default) or \"advanced\".
Optional MAX-RESULTS is the maximum number of results (default 5)."
  (let* ((url "https://api.tavily.com/search")
         (search-depth (or search-depth "basic"))
         (max-results (or max-results 5))
         (request-data
          `(("api_key" . ,(tavily-api-key))
            ("query" . ,query)
            ("search_depth" . ,search-depth)
            ("max_results" . ,max-results))))
    (plz 'post url
      :headers '(("Content-Type" . "application/json"))
      :body (json-encode request-data)
      :as 'string
      :then (lambda (result) (funcall callback result)))))

(defun fetch-url-text-async (callback url)
  "Fetch text content from URL."
  (require 'plz)
  (require 'shr)
  (plz 'get url
    :as 'string
    :then (lambda (html)
            (with-temp-buffer
              (insert html)
              (shr-render-region (point-min) (point-max))
              (shr-link-to-markdown)
              (funcall callback (buffer-substring-no-properties (point-min) (point-max)))))))

(defun shr-link-to-markdown ()
  "Replace all shr-link in the current buffer to markdown format"
  (goto-char (point-min))
  (while (setq prop (text-property-search-forward 'shr-url))
    (let* ((start (prop-match-beginning prop))
           (end (prop-match-end prop))
           (text (buffer-substring-no-properties start end))
           (link (prop-match-value prop)))
      (delete-region start end)
      (goto-char start)
      (insert (format "[%s](%s)" text link)))))

用 gptel 可以先用总结能力强的模型搜索资料并总结,然后换一个写作能力强的模型来创作,这在其他与模型绑定的 app 中似乎是无法做到的(或者要自己手动复制粘贴)。

9 个赞

也可以用 brave

(defun brave-search-query (query)
  "Perform a web search using the Brave Search API with the given QUERY."
  (let ((url-request-method "GET")
        (url-request-extra-headers `(("X-Subscription-Token" . ,brave-search-api-key)))
        (url (format "https://api.search.brave.com/res/v1/web/search?q=%s" (url-encode-url query))))
    (with-current-buffer (url-retrieve-synchronously url)
      (goto-char (point-min))
      (when (re-search-forward "^$" nil 'move)
        (let ((json-object-type 'hash-table)) ; Use hash-table for JSON parsing
          (json-parse-string (buffer-substring-no-properties (point) (point-max))))))))

我觉得 elisp 用户自己手搓 tool 其实真的很方便的。无非就是把函数的 API 写成 LLM 能读懂的 json schema 罢了。

现在这个很火的 MCP 我是真的有点不太喜欢。虽然说理论上说这个 MCP 是提供了标准化的平台方便工具共享,从而使得模型的调用者不必实现工具调用的逻辑。

但是问题是,MCP 本身在后台是通过 json rpc 进行交互,也就是说一个 tool 就要有一个在后台常驻的 daemon,然后如果你用很多工具的话,想象一下会有多少在后台常驻的进程。再加上现在大量的支持 MCP 的 tool 都是用 node 实现的,有 computing 洁癖的话,真的不能接受在后台有这么多常驻的 node tool。

在这一点上,MCP 和 LSP 有很大的不同是:LSP 本身定义的 规范里能够实现的东西确实非常复杂,要把这一套规范里的 capabilities / providers 实现起来确实需要非常花费精力,因此提供一套标准化的接口,避免每一个编辑器自己都需要实现一套非常复杂的分析编程语言的工具,确实很有必要。

但是在 MCP 这个场景上,本身 LLM 就是通过阅读理解 json schema 来调用工具,而能够用 json schema 就能够描述的工具,在复杂度上就不可能太高,用户自己手搓也完全不是问题。

MCP 这套工具有一个好处就是方便闭源/商业软件发布调用自己的工具。如果没有 MCP 的话,它们如果想让用户实现工具调用,就必须要发布一套自己的稳定的 API,这样程序员就能通过访问他们的接口来自己实现工具调用。现在有了 MCP 它们就不需要考虑 API 的事情了,就只需要发布一个 MCP,然后把工具包装成 json schema 来做就行了。

4 个赞

但是问题是,MCP 本身在后台是通过 json rpc 进行交互,也就是说一个 tool 就要有一个在后台常驻的 daemon,然后如果你用很多工具的话,想象一下会有多少在后台常驻的进程。再加上现在大量的支持 MCP 的 tool 都是用 node 实现的,有 computing 洁癖的话,真的不能接受在后台有这么多常驻的 tool。

实际上是一个 mcp server 是一个进程,一个 server 可以提供多个 tool ,并不是一个 tool 一个进程。

mcp server 还可以跑在远程服务器上,通过 HTTP SSE 来进行连接,这也是目前 mcp.el 下一步要支持的事情。

mcp server 还提供了 resources 的概念,用来管理工具调用中产生的资源文件。

1 个赞

实际上是一个 mcp server 是一个进程,一个 server 可以提供多个 tool ,并不是一个 tool 一个进程。

我的描述不太精准,但是大致意思是一样的。一个 server 可以提供若干个 tool,但是每个 server 都是一个独立的进程。那如果我要用若干个 server,那就是会有若干个后台进程了。

1 个赞

没问题的老哥。虽然我个人是不太喜欢 MCP,但是我还是要给你点赞。表达一波支持。

我个人不喜欢 MCP 的主要原因还是对于让 LLM+ remote 操控我的 computing environment 的深层恐惧感。在当下我用 LLM,我还是有一定的掌控感。首先 LLM 现在只能输出文字,即便是 agent 能做的事情也有限,即便是调用工具也只能够调用我设计好的工具,这样我还有一定的掌控感。

但是 MCP 的设计,把所有的工具都变成一个个 server,让我感觉到一切皆 SaaS 的感觉。一个又一个 MCP 把服务架设在远程让 LLM 操控。这让我无形之中感觉到了人的异化。

哪怕是像 manus / openai agent 做的那样,让 AI 在一个虚拟机环境里自己想干啥就干啥,想运行 bash 就运行 bash,想用浏览器上网就上网,都让我有稍稍安全一些的感觉。至少他们是运行在一个虚拟机里,他们相对于我而言是仍然保持了相对的独立性。

3 个赞

借贵宝地问个问题:gptel 中我在配置里已经添加了 ollama 的支持,但日常调用时,还是会默认 gpt,每一次我都要通过 gptel-menu 来调整。

我应该如何调整这个默认的大模型?

Optional) Set as the default gptel backend

The above code makes the backend available to select. If you want it to be the default backend for gptel, you can set this as the value of gptel-backend. Use this instead of the above.

   ;; OPTIONAL configuration
(setq
 gptel-model 'mistral:latest
 gptel-backend (gptel-make-ollama "Ollama"
                 :host "localhost:11434"
                 :stream t
                 :models '(mistral:latest)))

可以进官方文档, 有说明

那就是我看漏了,我只看到了 这部分,没意识到还要包起来。

(gptel-make-ollama "Ollama"
                 :host "localhost:11434"
                 :stream t
                 :models '(mistral:latest)))