在国行 macOS 下用 DeepSeek 补齐 Xcode 26 的 AI 能力:问题、原因与 mitmproxy 解决方案(含可用脚本与安装教程)

摘要:在中国大陆销售的国行 Mac 上,Apple Intelligence 的某些在线 AI 功能通常受限或不可用。Xcode 26 引入了对外部大型模型(LLM)接入的能力,官方对 Claude 的支持较好,但 Claude 成本高昂,许多开发者希望接入性价比更优的 DeepSeek。实际接入过程中会遇到两类典型错误:

  1. Invalid 'tools': empty array. Expected an array with minimum length 1, but got an empty array instead. ------ 原因是请求或响应中出现空的 tools: [] 数组;

  2. Failed to deserialize the JSON body into the target type: messages[2]: invalid type: sequence, expected a string ... ------ 原因是 Xcode(或 IDE)发出的 messages[].content 字段是数组结构,但 DeepSeek 期望它是字符串。

本文解释这些错误的成因,并提供一个安全、可控 的本地 mitmproxy 方案来修正请求/响应(仅针对 api.deepseek.com),包含脚本、安装与调试步骤。

背景:为什么要把 Xcode 接入 DeepSeek?

  • 国行 macOS 与 Apple Intelligence:Apple 在不同地区对在线 AI 功能有不同的政策与接口可用性。国行 Mac 的 Apple Intelligence(或 Apple 的一些云 AI 功能)常常受限或被下线,导致 IDE(例如 Xcode)的智能补全、智能重构、代码搜索等 AI 能力无法或受限地工作。
  • Xcode 26 的扩展性:Xcode 26 支持把外部模型接入到 IDE 的 Intelligence 流程里,理论上可以用任何兼容 provider 填补 Apple Intelligence 的功能空缺。
  • Claude vs DeepSeek:Xcode 对 Claude(Anthropic)的支持很好,但 Claude 的定价较高。DeepSeek(或其它性价比更高的 LLM 提供商)成为很多开发者的首选 --- 代价更低、响应灵活。但 DeepSeek 与 Xcode 的集成并非"开箱即用",需要做些兼容工作。

两个常见错误:原因与本质

错误一(格式校验)------ Invalid 'tools': empty array...

报错

php 复制代码
Invalid 'tools': empty array. Expected an array with minimum length 1, but got an empty array instead.

原因

Xcode 26 的 Intelligence 模块在与 LLM 交互时,遵循某种扩展的 Chat Completions/Function-calling 风格(类似 OpenAI 的 function / tools 概念)。当响应或请求中包含 tools 字段时,Xcode 期望 tools非空数组 (至少 1 个元素),用于表示可被调用的"工具"(例如 editCode 等)。但 DeepSeek 在某些情况会返回 tools: [](空数组),或 Xcode 发出的请求里包含 tools: []。空数组就违反了 Xcode 的校验规则,于是抛出该错误并中止处理。

解决思路

  • 最直接的做法是在代理层将空的 tools 字段移除(或替换成包含至少一个"占位"工具的数组),使 Xcode 接收到符合期望的 JSON。
  • 我们推荐删除空 tools 字段(比填入伪造 tool 更安全、风险更小),仅在必要时补入占位工具。

错误二(类型不匹配)------ messages[2]: invalid type: sequence, expected a string ...

报错

go 复制代码
Failed to deserialize the JSON body into the target type: messages[2]: invalid type: sequence, expected a string at line 1 column 6733

原因

此错误发生在 Xcode 自动向 DeepSeek 发起的后续请求中。DeepSeek(或 Xcode 的后端)期待 messages 数组里每个 message 的 content 字段是一个 字符串 (plain text)。但 Xcode(或 IDE 的内部生成逻辑)在某些场景会把 message 的 content数组/结构化内容 的形式发送(例如分段的对象数组 { "content": [ { "text": "..." }, {...} ] })。服务端在反序列化时期待字符串,见到数组(sequence),于是报错。

解决思路

  • 在转发请求前检测 messages 内的 content :若是数组/结构化数据,就按规则把它"扁平化"为字符串(例如把 content 数组中的每个子项的 textcontent 字段抽取并按顺序拼接为一个字符串,必要时用换行或空格分隔)。
  • 同时仍保留对 tools 的处理(删除空 tools),从而确保 Xcode 与 DeepSeek 间的请求/响应都符合双方期望的 schema。

推荐方案:使用 mitmproxy 在本机拦截并修正请求/响应(只针对 api.deepseek.com

为什么选择 mitmproxy?

  • mitmproxy 支持 HTTPS 拦截(需安装根证书),可以在请求/响应层面透明修改 JSON
  • 比起修改 Xcode/SDK,本地代理方式对环境侵入更小、可控、易回滚。
  • 通过只对 api.deepseek.com 生效,可以把影响范围降到最低、保证安全。

重要安全说明:mitmproxy 会解密 HTTPS 流量,请在受信任环境中使用(本地开发机)。避免在公共网络或生产环境长期运行 mitmproxy。修改请求/响应时注意不要泄露 API 密钥或敏感数据到日志。


可直接使用的脚本(只对 api.deepseek.com 生效;删除空 tools;扁平化 messages[].content)

将下列脚本保存为 modify_tools_flatten_content_api_only.py(推荐路径:~/mitm-scripts/modify_tools_flatten_content_api_only.py):

python 复制代码
# modify_tools_flatten_content_api_only.py
# mitmproxy addon:
# 1) only operate on flows targeting api.deepseek.com
# 2) remove empty "tools": [] from request/response JSON (root or nested)
# 3) flatten message.content arrays in requests into a single string (to satisfy DeepSeek expecting string)
#
# Usage:
#   mitmproxy -p 8080 -s modify_tools_flatten_content_api_only.py

from mitmproxy import http, ctx
import json
from typing import Any, Optional

TARGET_HOST = "api.deepseek.com"

def is_target_flow(flow: http.HTTPFlow) -> bool:
    """
    Return True if this flow is for the target host (api.deepseek.com).
    Checks multiple places for robustness.
    """
    try:
        host_header = flow.request.headers.get("host", "")
    except Exception:
        host_header = ""

    # flow.request.host or pretty_host may be available depending on mitmproxy version
    flow_host = getattr(flow.request, "host", "") or getattr(flow.request, "pretty_host", "")
    flow_host = flow_host or host_header

    # Normalize (lowercase) and compare
    return str(flow_host).lower() == TARGET_HOST

def remove_empty_tools_in_obj(obj: Any) -> bool:
    """
    Recursively remove empty `tools: []` in dict/list structures.
    Returns True if any modification was made.
    """
    modified = False

    if isinstance(obj, dict):
        if "tools" in obj and isinstance(obj["tools"], list) and len(obj["tools"]) == 0:
            del obj["tools"]
            modified = True

        # handle choices[].message.tools and choice.tools
        if "choices" in obj and isinstance(obj["choices"], list):
            for choice in obj["choices"]:
                if isinstance(choice, dict):
                    msg = choice.get("message")
                    if isinstance(msg, dict):
                        if "tools" in msg and isinstance(msg["tools"], list) and len(msg["tools"]) == 0:
                            del msg["tools"]
                            choice["message"] = msg
                            modified = True
                    if "tools" in choice and isinstance(choice["tools"], list) and len(choice["tools"]) == 0:
                        del choice["tools"]
                        modified = True

        # recurse
        for k, v in list(obj.items()):
            if isinstance(v, (dict, list)):
                if remove_empty_tools_in_obj(v):
                    modified = True

    elif isinstance(obj, list):
        for item in obj:
            if isinstance(item, (dict, list)):
                if remove_empty_tools_in_obj(item):
                    modified = True

    return modified

def flatten_message_content_in_messages(obj: Any) -> bool:
    """
    If obj has a 'messages' key that is a list, and any message has 'content' that is a list,
    flatten that content array into a single string (prefer item['text'] if exists).
    Returns True if modified.
    """
    modified = False

    if not isinstance(obj, dict):
        return False

    messages = obj.get("messages")
    if not isinstance(messages, list):
        return False

    for i, msg in enumerate(messages):
        if not isinstance(msg, dict):
            continue
        content = msg.get("content")
        # if content is list/array -> flatten
        if isinstance(content, list) and len(content) > 0:
            parts = []
            for part in content:
                # if the part is dict with 'text' field
                if isinstance(part, dict):
                    txt = None
                    if "text" in part and isinstance(part["text"], str):
                        txt = part["text"]
                    elif "content" in part and isinstance(part["content"], str):
                        txt = part["content"]
                    elif "value" in part and isinstance(part["value"], str):
                        txt = part["value"]
                    else:
                        try:
                            txt = json.dumps(part, ensure_ascii=False)
                        except Exception:
                            txt = str(part)
                    parts.append(txt)
                elif isinstance(part, str):
                    parts.append(part)
                else:
                    try:
                        parts.append(json.dumps(part, ensure_ascii=False))
                    except Exception:
                        parts.append(str(part))
            new_content = "\n".join(p for p in parts if p is not None)
            messages[i]["content"] = new_content
            modified = True

        # also handle case where content is dict with nested arrays (parts/items)
        elif isinstance(content, dict):
            for key in ("parts", "items", "segments"):
                if key in content and isinstance(content[key], list):
                    parts = []
                    for part in content[key]:
                        if isinstance(part, dict) and "text" in part and isinstance(part["text"], str):
                            parts.append(part["text"])
                        elif isinstance(part, str):
                            parts.append(part)
                        else:
                            try:
                                parts.append(json.dumps(part, ensure_ascii=False))
                            except Exception:
                                parts.append(str(part))
                    if parts:
                        messages[i]["content"] = "\n".join(parts)
                        modified = True
                        break

    if modified:
        obj["messages"] = messages

    return modified

class ToolAndContentFixer:
    def request(self, flow: http.HTTPFlow) -> None:
        """
        Inspect JSON request bodies for target host:
         - remove empty tools arrays
         - flatten messages[].content arrays into a single string
        """
        if not is_target_flow(flow):
            return

        # if no body, nothing to do
        if not flow.request.content:
            return

        # check likely JSON (content-type or starting char)
        ctype = flow.request.headers.get("content-type", "")
        looks_like_json = ("json" in ctype.lower()) or flow.request.content.strip().startswith(b"{") or flow.request.content.strip().startswith(b"[")
        if not looks_like_json:
            return

        try:
            text = flow.request.get_text(strict=False)
        except Exception as e:
            ctx.log.warn(f"modify_tools: failed to read request body text: {e}")
            return

        if not text:
            return

        try:
            data = json.loads(text)
        except Exception:
            # not valid JSON
            return

        modified1 = remove_empty_tools_in_obj(data)
        modified2 = flatten_message_content_in_messages(data)
        if modified1 or modified2:
            new_text = json.dumps(data, ensure_ascii=False)
            flow.request.set_text(new_text)
            ctx.log.info(
                f"modify_tools: patched request {flow.request.method} {flow.request.pretty_url} "
                f"(removed tools: {modified1}, flattened content: {modified2})"
            )

    def response(self, flow: http.HTTPFlow) -> None:
        """
        Inspect JSON responses for target host and remove empty tools arrays.
        """
        if not is_target_flow(flow):
            return

        if not flow.response.content:
            return

        try:
            text = flow.response.get_text(strict=False)
        except Exception:
            return

        if not text:
            return

        if not (text.lstrip().startswith("{") or text.lstrip().startswith("[")):
            return

        try:
            data = json.loads(text)
        except Exception:
            return

        modified = remove_empty_tools_in_obj(data)
        if modified:
            new_text = json.dumps(data, ensure_ascii=False)
            flow.response.set_text(new_text)
            ctx.log.info(f"modify_tools: removed empty 'tools' from response for {flow.request.method} {flow.request.pretty_url}")

addons = [
    ToolAndContentFixer()
]

mitmproxy 在 macOS 上的安装与使用教程(简化版,结合本脚本)

下面是从安装 mitmproxy、导入根证书,到加载脚本和在 Xcode 中验证的可执行步骤(摘取并精简自之前详尽教程):

1)安装 mitmproxy(Homebrew 推荐)

bash 复制代码
brew install mitmproxy

或用 pip(虚拟环境)安装:

bash 复制代码
python3 -m venv ~/.venv/mitm
source ~/.venv/mitm/bin/activate
pip install mitmproxy

2)把脚本保存好

例如:

bash 复制代码
mkdir -p ~/mitm-scripts
# 把上面脚本保存为
~/mitm-scripts/modify_tools_flatten_content_api_only.py

3)启动 mitmproxy 并加载脚本(默认 8080 端口)

bash 复制代码
mitmproxy -p 8080 -s ~/mitm-scripts/modify_tools_flatten_content_api_only.py

或用图形界面:

bash 复制代码
mitmweb -p 8080 -s ~/mitm-scripts/modify_tools_flatten_content_api_only.py

4)安装并信任 mitmproxy 根证书(必须,以便拦截 HTTPS)

推荐一键安装(会安装到当前用户登录钥匙串):

bash 复制代码
mitmproxy --install-cert

如果你需要让系统级应用(如 Xcode)也信任证书,请把证书导入 System 钥匙串并设为 Always Trust (需要管理员权限)。也可以通过 Keychain Access 手动导入 ~/.mitmproxy/mitmproxy-ca-cert.pem

5)让 macOS 走代理(让 Xcode 的请求被拦截)

  • 打开 系统设置 → 网络 → 所用网络接口 → 高级 → 代理 ,勾选 HTTP/HTTPS 代理,填写 127.0.0.1 端口 8080。保存后系统级流量(以及多数应用)会走 mitmproxy。
  • 注意 :部分内部进程或直接使用 system APIs 的请求可能会绕过系统代理。如果 Xcode 仍然没有走代理,可以考虑使用 pfctl 做透明重定向(高级、需谨慎)。

6)测试

  • curl 通过代理测试(在终端):
bash 复制代码
curl -x http://127.0.0.1:8080 -v https://api.deepseek.com/v1/models
  • 在 Xcode 里触发 Intelligence 请求(或按你原来的流程操作),检查 mitmproxy 控制台,脚本应打印类似:
sql 复制代码
modify_tools: patched request POST https://api.deepseek.com/v1/chat/completions (removed tools: True, flattened content: True)
modify_tools: removed empty 'tools' from response for GET https://api.deepseek.com/v1/models

这说明脚本生效并已对请求/响应做了必要修改。

常见问题与排错建议(精简版)

  1. 没有拦截到请求

    • 确认 mitmproxy 正在运行并监听预期端口;
    • 确认系统代理已设置为 127.0.0.1:8080
    • 对 curl 测试通过 -x 指定代理,验证 mitmproxy 是否能拦截;
    • 若应用绕过系统代理(部分系统级流程),可用 pfctl 做透明重定向(需谨慎)。
  2. 证书/HTTPS 问题

    • 证书未被信任会导致 TLS 错误。把 mitmproxy 根证书导入 System keychain 并设置 Always Trust。重启 Xcode 以确保信任生效。
    • 开发中可用 curl -k 暂时忽略 TLS,但目标是把证书安装并信任。
  3. 仍然返回 403/鉴权错误

    • 代理不要删除或修改重要 headers(例如 Cookie/Authorization)。我们的脚本只改 body(删除空 tools 和扁平化 messages),不会触碰 headers。若你自己在其它代理/脚本中改了 header 需要恢复。
    • Cloudflare / WAF 规则可能会基于 IP、UA 等做额外检查,尽量保持请求的原生特征(User-Agent、Host 等)。
  4. 日志或数据敏感性

    • 脚本会记录被修改的请求到 mitmproxy 控制台,避免在日志中输出敏感信息(可按需注释掉 log 行或将日志写入只读受限的本地文件)。

总结与建议

  • 对于国行 macOS 用户,Apple Intelligence 的局限确实影响 IDE 智能体验;通过把 Xcode 的 Intelligence 接入 DeepSeek(或其它第三方模型)可以在一定程度上弥补这一缺失。
  • Xcode 26 自带的模型接入能力强,但不同模型/提供商之间在 API 格式上仍存在细微差别(例如 toolsmessages[].content 的类型)。这些差异会导致校验/反序列化错误(如本文开头展示的两类典型错误)。
  • 最稳妥的短期方案是本地代理修正 :使用 mitmproxy 拦截并修改流量,只对 api.deepseek.com 生效,删除空 tools、把数组 content 扁平化为字符串,从而让 Xcode 与 DeepSeek 相互兼容。
  • 长期来看,最好让模型提供方(DeepSeek)在其 API 层面增强与 Xcode/OpenAI 风格 schema 的兼容性,或让 Xcode 能更灵活地处理 toolsmessages 的不同表示方式。
相关推荐
算家计算3 天前
DeepSeek-R1论文登《自然》封面!首次披露更多训练细节
人工智能·资讯·deepseek
CHB4 天前
uni-ai:让你的App快速接入AI
uni-app·deepseek
孙半仙人5 天前
SpringAI接入DeepSeek大模型实现流式对话
springai·deepseek
大模型真好玩5 天前
大模型工程面试经典(七)—如何评估大模型微调效果?
人工智能·面试·deepseek
量子位10 天前
DeepDiver-V2来了,华为最新开源原生多智能体系统,“团战”深度研究效果惊人
ai编程·deepseek
封奚泽优10 天前
班级互动小程序(Python)
python·deepseek
陈敬雷-充电了么-CEO兼CTO10 天前
视频理解新纪元!VideoChat双模架构突破视频对话瓶颈,开启多模态交互智能时代
人工智能·chatgpt·大模型·多模态·世界模型·kimi·deepseek
大模型真好玩10 天前
大模型工程面试经典(五)—大模型微调与RAG该如何选?
人工智能·面试·deepseek
文 丰11 天前
【centos7】部署ollama+deepseek
centos·deepseek