用 url.el 处理 SSE 流式输出时,遇到奇怪的输出(百度千帆大模型 API)

遇到一个困扰我好几天的问题,使用百度千帆大模型 API 时,用 url-retrieve 结合 after-change-functions 异步处理输出数据,但是中途的输出总是夹杂着奇怪的输出,比如下图中的 1f9 等等,最诡异的是最终的输出却又没了,实在不明白是怎么回事儿。

(defun qianfan-handle-new-content (_ _ old-len)
  (when (= old-len 0)
    (message "%S"
             (decode-coding-string
              (buffer-substring-no-properties (point-min) (point-max))
              'utf-8))))

(defun qianfan (question)
  (interactive "s千帆大模型: ")
  (let ((user-buffer (current-buffer)))
    (let ((url-request-method "POST")
          (url-request-extra-headers '(("Content-Type" . "application/json")))
          (url-request-data (encode-coding-string
                             (json-serialize
                              `((stream . t)
                                (messages . [((role . "user")
                                              (content . ,question))])))
                             'utf-8)))
      (with-current-buffer
          (url-retrieve
           (concat "https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop/chat/eb-instant?access_token="
                   "24.b2ecc710a1d00bc3b6bba3f8b90a2a7b.2592000.1708426726.282335-47373095")
           (lambda (_)
             (remove-hook 'after-change-functions #'qianfan-handle-new-content t))
           nil t t)
        (add-hook 'after-change-functions #'qianfan-handle-new-content nil t)
        (setq qianfan-handle-response
              (lambda (json)
                (with-current-buffer user-buffer
                  (let-alist json
                    (insert .result)))))
        (pop-to-buffer-same-window (current-buffer))))))

下面是 curl 输出,方便对照 SSE 的输出格式。可以用我的 Token 测试,过一段时间后我会重置。

➜  ~ curl --json '{"stream": true, "messages":[{"role":"user","content":"请介绍下你自己"}]}' \
     'https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop/chat/eb-instant?access_token=24.f4b93675c21a394307adb62a97ef8ff9.2592000.1708174840.282335-47373095'

data: {"id":"as-tktpbea4f7","object":"chat.completion","created":1705838053,"sentence_id":0,"is_end":false,"is_truncated":false,"result":"嗨,","need_clear_history":false,"usage":{"prompt_tokens":4,"completion_tokens":0,"total_tokens":4}}

data: {"id":"as-tktpbea4f7","object":"chat.completion","created":1705838053,"sentence_id":1,"is_end":false,"is_truncated":false,"result":"我是百度公司的人工智能语言模型,我叫文心一言。","need_clear_history":false,"usage":{"prompt_tokens":4,"completion_tokens":0,"total_tokens":4}}

data: {"id":"as-tktpbea4f7","object":"chat.completion","created":1705838053,"sentence_id":2,"is_end":false,"is_truncated":false,"result":"我非常善于介绍自己,我的功能就是和用户进行自然、流畅的对话交流,提供帮助和信息,以及回答各种类型的问题。","need_clear_history":false,"usage":{"prompt_tokens":4,"completion_tokens":46,"total_tokens":50}}

data: {"id":"as-tktpbea4f7","object":"chat.completion","created":1705838054,"sentence_id":3,"is_end":false,"is_truncated":false,"result":"无论您有什么需要,都可以向我提问,我很乐意为您解答。","need_clear_history":false,"usage":{"prompt_tokens":4,"completion_tokens":46,"total_tokens":50}}

data: {"id":"as-tktpbea4f7","object":"chat.completion","created":1705838054,"sentence_id":4,"is_end":true,"is_truncated":false,"result":"","need_clear_history":false,"usage":{"prompt_tokens":4,"completion_tokens":59,"total_tokens":63}}

我可以确定百度的 API 是正常的。我对 url.el 的印象一直就不太好,总觉得不大靠谱,不知道是不是我有啥没注意到,因为我试用过 llm.el + OpenAI,它是正常的,我的思路就是来自于此。

盲猜是因为把 raw 当成 utf-8 解码造成的

那个应该是 chunk 的长度,url-retrieve 好像会把 chunk 开头的长度也读进 buffer 然后再删除掉。具体请看 url-http.el 中的 url-http-chunked-encoding-after-change-function.

联想到可能和编码有关,我再次用中文试了试 llm.el + OpenAI,发现其实有同样的问题,中途也有奇怪的字符:

(llm-chat-streaming-to-point
 (make-llm-openai :key "sk-xxx.xxx")
 (make-llm-chat-prompt
  :interactions (list
                 (make-llm-chat-prompt-interaction
                  :role 'user
                  :content "请用一句话介绍下 OpenAI")))
 (current-buffer)
 (point)
 (lambda () (message "done")))

但是 llm.el 还能正常工作的,因为作者做了一定的验证,估计能排出至少大部分的异常输出。

我起初看到 Transfer-Encoding: chunked 也奇怪,curl 的返回并没有,应该是 Emacs 自己加的,不像是服务器返回的。Transfer-Encoding: chunked 使用 \r\n,但是 SSE 使用 \n\n,这又怎么混到一起了呢,能一起用吗?

遇到过有些网站可能是为了防止爬虫,会在正常的内容里夹杂一些稀奇古怪的乱码,还是无规律的,时有时无, 让人误以为是自己的代码有什么问题,然后去找问题原因从而掉很多头发。

当然这只是一种可能,,楼主遇到的问题未必就是这个原因

1 个赞

又看了一下,应该就是我上面所说的原因。

curl 无法复现是因为 curl 默认用 http 2. 如果加上 --http1.1 就会使用 chunked encoding 了。

url-http 不支持 http2, 要想不用 chunked encoding,可以把 url-http-version 设为 “1.0”.

1 个赞

嗯嗯,你分析的没错。

试了下,确实如你所言。

试了下,这样确实可以。又发现一个不方便, url-retrieve 绑定 url-http-version 没用,只能 setq

;; 可以
(let ((url-http-version "1.0"))
  (display-buffer
   (url-retrieve-synchronously "https://example.com/")))

;; 不可以
(let ((url-http-version "1.0"))
  (display-buffer
   (url-retrieve "https://example.com/")))

;; 可以,当然不理想
(setq url-http-version "1.0")
(display-buffer
 (url-retrieve "https://example.com/" (lambda ())))

不想再用 url.el 了,等有机会再考虑用 curl + start-process + process filter 试试看,可惜没有现成,这儿有个提议,但是还没有实现:

之前让 emacs-aichat 支持 sse 时也折腾了好久,最后还是 curl 好用,不过也通过一些 hack 的方式 让 url.el 支持了 sse

1 个赞

下面一个我这儿可以工作的参考代码,我试了几次,但不能保证可靠性,供参考思路。

CleanShot 2024-01-22 at 20.30.31

(defvar-local qianfan-last-response 0)
(defvar-local qianfan-handle-response nil)

(defun qianfan-handle-new-content (_ _ old-len)
  (when (= old-len 0)
    (save-excursion
      (save-match-data
        (goto-char (point-min))
        (re-search-forward "\n\n" nil t (1+ qianfan-last-response))
        (while (re-search-forward "\n\n" nil t)
          (let* ((str (save-excursion
                        (forward-line -2)
                        (decode-coding-string
                         (buffer-substring-no-properties
                          (+ (line-beginning-position) (length "data: "))
                          (line-end-position))
                         'utf-8)))
                 (json (json-parse-string str :object-type 'alist)))
            (funcall qianfan-handle-response json)
            (cl-incf qianfan-last-response)))))))

(setq url-http-version "1.0")

(defun qianfan (question)
  (interactive "s千帆大模型: ")
  (let ((user-buffer (current-buffer))
        (url-request-method "POST")
        (url-request-extra-headers '(("Content-Type" . "application/json")))
        (url-request-data (encode-coding-string
                           (json-serialize
                            `((stream . t)
                              (messages . [((role . "user")
                                            (content . ,question))])))
                           'utf-8)))
    (with-current-buffer
        (url-retrieve
         (format
          "https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop/chat/%s?access_token=%s"
          "eb-instant"
          "24.b2ecc710a1d00bc3b6bba3f8b90a2a7b.2592000.1708426726.282335-47373095")
         (lambda (_)
           (remove-hook 'after-change-functions #'qianfan-handle-new-content t))
         nil t t)
      (add-hook 'after-change-functions #'qianfan-handle-new-content nil t)
      (setq qianfan-handle-response
            (lambda (json)
              (with-current-buffer user-buffer
                (let-alist json
                  (insert .result))))))))

因为发送异步请求的时候,已经离开 let 作用域了。先前有讨论过:

2 个赞

明白了怎么处理。url-retrieve 的 docstring 提示了 let-binding 其他变量可能没用,所以起初我也不意外。

;; 绑定 url-http-version 等 let-binding 不起作用的变量
(with-current-buffer (url-retrieve "https://example.com/" (lambda ()))
  (setq-local url-http-version "1.0")
  (display-buffer (current-buffer)))