主题
用一个统一接口 + 抽象基类 + 工厂,把 GLM / DeepSeek / Step / Kimi / FreeLlmApi / Xfyun 六家 provider 的差异收敛到「子类只 override 差异点」,上层 Agent 完全不感知用的是哪家模型。
提示词分层与模型接入
PromptAssembler.assemble 的组装顺序:base.md → personalities/calm.md → modes/{agent|plan|planner|team-*}.md( 变量替换)→ approvals/{suggest|auto|never}.md → Runtime Context(日期/时区)→ Project Context(CODEAGENT.md + 检索记忆 + MCP resource 索引)→ Skills 索引 → context-management.md → handoff.md。带硬校验(## Language 段必须存在)与防御(不支持工具时正则剥离 Tools 段并注入防伪造声明)。PromptRepository.loadRequired 三层覆盖:jar 内置 → ~/.codeagent/prompts/ → .codeagent/prompts/(项目级最高)。
模型接入:LlmClient 接口带能力声明(maxContextWindow / supportsPromptCaching / promptCacheMode),AbstractOpenAiCompatibleClient 模板基类统一 SSE 解析(data: 行、reasoning_content/reasoning/reasoning_details 三种字段兼容、tool_calls delta 按 index 累积、cached tokens 多家字段兼容),五个瘦实现(GLM/DeepSeek/Step/Kimi/FreeLlm)——新增模型 = 一个瘦子类。
多模型适配实现
三层结构。
第一层:统一接口 LlmClient。 定义所有能力契约:chat(带流式)、getModelName、getProviderName、maxContextWindow、supportsTools、supportsPromptCaching、promptCacheMode。上层只依赖接口。
第二层:抽象基类 AbstractOpenAiCompatibleClient(核心)。 把所有 OpenAI 兼容协议的公共流程一次写完:SSE 流式解析、请求体构造、tool_calls 的增量合并(流式里 function name/arguments 分片下发,用 ToolCallAccumulator 按 index 拼起来,)、reasoning 的多字段兼容提取(reasoning_content / reasoning / reasoning_details 三种格式都能读)、token 统计。同时留一堆可 override 的 hook:getApiUrl/getModel/getApiKey(必实现)、shouldSendReasoningContentInRequestHistory / customizeRequestBody / customizeRequest / httpClient / toImageUrl / supportsTools 等。
第三层:工厂 LlmClientFactory。 一个 switch 按 provider 名创建对应 client,还做别名归一化(stepfun→step、moonshot→kimi、xfyun-maas→xfyun)和 createFromConfig 的兜底遍历(默认 provider 没 key 就按顺序找下一个有 key 的)。
【主动抛深度点 1:模板方法模式】 这是整个多模型适配的灵魂。基类把「怎么发请求、怎么解析 SSE、怎么拼 tool_calls」这些 90% 相同的逻辑全实现了,只把差异留成可 override 的 hook。子类不是「各自写一套 HTTP 调用」,而是「override 几个点」。好处:加新 provider 就是建个十几行的子类,公共逻辑零改动;改一个公共 bug(比如 SSE 解析)六家一起修好。
【主动抛深度点 2:差异点适配是「踩坑踩出来的」】 每个 override 背后几乎都是一个真实的坑,举两个最典型的:DeepSeek / Kimi 的 thinking 模型,reasoning_content 必须随下一轮请求历史带回,否则模型会「失忆」——所以这俩 override shouldSendReasoningContentInRequestHistory 返回 true;DeepSeek 的 SSE 长流在部分网关的 HTTP/2 下会被远端重置成 stream was reset,所以强制 HTTP/1.1。这些不是「设计出来的差异」,是「接入时逐个踩坑修出来的兼容」。
用了什么设计模式?
模板方法模式 + 工厂模式。模板方法收敛公共流程(基类定骨架、子类填差异),工厂屏蔽创建细节。再加一个策略模式的影子——LlmClient 接口就是策略,运行时可以换不同 provider 实现。
加一个新 provider 要改哪些地方?
三步:建一个 XxxClient extends AbstractOpenAiCompatibleClient,override 差异点(URL / 模型名 / 窗口 / 特殊字段);工厂 switch 加一个分支;.env 加对应 key。公共逻辑零改动,这是模板方法模式最大的收益。
reasoning_content 为什么有的模型要带回历史?
因为 thinking 模型(DeepSeek V4、Kimi thinking)的思考过程在它自己的 API 里是「多轮连贯」的——如果上一轮的 reasoning_content 不回传,下一轮模型就丢了思考上下文。所以这些 client override shouldSendReasoningContentInRequestHistory 返回 true,在构造请求体时把 assistant 消息的 reasoning_content 也带上;其他 provider 默认不回传,只把 reasoning 写日志/展示。
为什么 DeepSeek 强制 HTTP/1.1?
实战踩坑:SSE 是长连接流,部分网络/网关在 HTTP/2 下会把长流重置成 stream was reset: INTERNAL_ERROR,导致生成中途断流。强制 HTTP/1.1 后规避了这个问题。这体现了「兼容适配」本质是解决真实环境的协议差异,不是抽象设计。
xfyun 为什么 supportsTools=false?
因为讯飞星辰 MaaS 按它的文档走的是纯对话请求,不接收 CodeAgent CLI 内置的工具列表。所以它 override supportsTools 返回 false,上层 Agent 看到 false 就不会组装 tool definitions,走纯对话路径。这正好说明接口里「supportsTools」这个能力位的作用——让不支持工具的 provider 能优雅降级,而不是硬塞工具列表导致报错。
输入到模型的 prompt 由哪些部分组成?
项目的 System Prompt 使用分层拼接,一共有 9 个主要层次,PromptAssembler 会按照固定顺序进行组装。
前四层依次是基础层、人格层、模式层和审批层。基础层定义 Agent 身份、语言、安全规范以及工具使用原则;人格层控制回答风格;模式层根据 Agent、Plan、Planner 或 Team 中的具体角色加载对应指令;审批层则规定危险操作的确认方式。
中间三层是运行时上下文,包括当前日期和时区、Project Context,以及 Skills 索引。Project Context 又由项目级 CodeAgent.md、当前问题检索到的长期记忆和 MCP Resource 索引组成,所以这一部分会在每轮请求前重新构建。
最后两层是上下文管理策略和 Handoff 指令,分别告诉模型如何处理长上下文,以及最终回答应该交付哪些信息。它们虽然排在后面,但本身是静态规则。
除了 System Prompt,真正请求模型时还会单独传入对话历史、当前用户消息以及 Tools Schema。Tools Schema 通过 API 的 tools 参数传递,并不属于 System Prompt 的第一层。
从必要性来说,基础身份和模式指令决定 Agent 的核心行为;Project Context 和 Skills 索引属于增强信息,即使缺失模型也可以回答,但会失去项目约束、历史经验以及按需加载专业工作流的能力。
为什么静态内容要排在最前面?
静态内容优先排列,主要是为了提高 Prompt Caching 的命中率。
大多数模型服务的 Prompt Cache 都基于相同前缀进行复用。因此我把身份、人格、运行模式和审批策略这类在一次会话中基本不变的内容放在前面;把检索记忆、项目上下文和 Skills 索引等可能变化的内容放在后面。
这样每轮请求虽然尾部上下文可能不同,但前面的大段稳定内容仍然可以复用缓存,从而减少重复计算,降低输入 Token 成本和首 Token 延迟。如果把动态内容插在前面,只要它发生变化,后续内容即使完全相同,也可能无法继续命中同一个前缀缓存。
这实际上是一种“稳定内容在前、易变内容在后”的 Prompt 布局策略。
八股延伸|讲一下动态 Prompt 和静态 Prompt
讲一下动态 Prompt 和静态 Prompt
“分成静态部分和动态部分。”
“静态部分在一个会话里不会变,构建一次就缓存。包括人格设定(语气风格)、核心行为准则(工具使用规范、安全策略)、项目指令(CODEAGENT.md 的内容)。”
“动态部分每次 LLM 请求都重新构建。包括当前时间和时区、工作目录路径、当前使用的模型名称和 provider、已启用的 Skill 索引、刚召回的相关长期记忆。”
“还有一个模式维度。ReAct 模式、Plan 模式、规划者/执行者/检查者各有独立的模式提示词文件,存在 resources/prompts/ 目录下。编排器根据当前角色选择对应的模式提示词,拼到系统提示词的对应位置。”
“项目指令的加载有优先级链:用户全局的 CODEAGENT.md → 项目根目录的 CODEAGENT.md → .codeagent 目录下的 CODEAGENT.md → CODEAGENT.local.md(本地覆盖,不提交仓库)。”
总预算 16KB,超出截断。用户还可以自定义覆盖任意一层的提示词文件,覆盖是整文件替换。
八股延伸|模型底座是哪个?成本怎么样?你平时用 CodeAgent CLI 吗?
模型底座是哪个?成本怎么样?你平时用 CodeAgent CLI 吗?
“默认模型是 DeepSeek V4。”
CodeAgent CLI 支持运行时切换模型,/model 命令可以在 DeepSeek、GLM、Kimi、StepFun 等 6 种 provider 之间切换,API Key 从配置文件、环境变量、.env 文件三个来源读取,优先级依次升高。
“1000 行代码大约 15000 到 20000 个 token。一次完整的编码任务,算上多轮 ReAct 循环,通常消耗 5 万到 10 万 token。”
用 DeepSeek V4 的话,成本大约 0.1 到 1 元之间。启用 Prompt Cache 后,重复的系统提示词和历史消息命中缓存,输入成本降到四分之一。
“用,每天都在用。写代码、查资料、自动化日常操作都用它。”
“有一说一,Claude Code 功能肯定比 CodeAgent CLI 强。但自己写的工具,最大的好处不是功能,而是你完全理解每一个设计决策背后的取舍。”
“而且在写 CodeAgent CLI 的过程中,我把 Agent 技术栈的核心概念全过了一遍。ReAct、Function Calling、RAG、MCP、Multi-Agent、Memory、Context 压缩。看文章看十遍不如自己动手实现一遍。”
源码锚点(读码定位用,面试不主动讲)
AbstractOpenAiCompatibleClient.java:334