Anthropic Messages 接口
Anthropic 的接口和 OpenAI 的 Chat Completions 思路相近,核心也是一份按时间顺序排列的 messages 数组,但有几处关键差异,下面逐条配上 JSON 直观对比。
OpenAI 是把 system 当成 messages 数组里的一种 role;Anthropic 把系统提示放在请求体顶层的 system 字段,messages 里只允许 user 和 assistant 两种角色。
OpenAI:
{
"model": "gpt-4o",
"messages": [
{ "role": "system", "content": "你是一个翻译助手" },
{ "role": "user", "content": "hello" }
]
}
Anthropic:
{
"model": "claude-sonnet-5",
"system": "你是一个翻译助手",
"messages": [
{ "role": "user", "content": "hello" }
]
}
OpenAI 里 max_tokens 可以省略走默认值,Anthropic 这里是请求体的必填项,不传会直接报错 422 状态码。
OpenAI:
{
"model": "gpt-4o",
"messages": [ { "role": "user", "content": "hello" } ]
}
Anthropic(少了 max_tokens 会 422):
{
"model": "claude-sonnet-5",
"max_tokens": 1024,
"messages": [ { "role": "user", "content": "hello" } ]
}
Anthropic 接口的角色只有两种:
user:用户的话(也承载tool_result)。assistant:模型之前的回答(含正文文本、深度思考块、工具调用块等)。
系统设定走顶层的
system字段,不要塞进messages。
工具结果回填成 user 消息。OpenAI 是用专门的 role:"tool" 回填;Anthropic 没有这个角色,工具结果以 tool_result 内容块的形式塞进一条 user 消息里,靠 tool_use_id 跟之前的 tool_use 块配对。
OpenAI:
[
{ "role": "assistant", "tool_calls": [ { "id": "call_1", "function": { "name": "get_weather", "arguments": "{\"city\":\"北京\"}" } } ] },
{ "role": "tool", "tool_call_id": "call_1", "content": "晴,25℃" }
]
Anthropic:
[
{ "role": "assistant", "content": [ { "type": "tool_use", "id": "toolu_1", "name": "get_weather", "input": { "city": "北京" } } ] },
{ "role": "user", "content": [ { "type": "tool_result", "tool_use_id": "toolu_1", "content": "晴,25℃" } ] }
]
内容是「块」而不是字符串,无论请求还是响应,一条消息的 content 都可以是一个数组,里面是 text、image、tool_use、tool_result、thinking 等带类型的「内容块」。SDK 把这些块建模成联合类型(OneOf),用 TryPickXxx 来拆。
OpenAI(content 多为字符串,工具调用另放在 tool_calls):
{ "role": "assistant", "content": "今天天气不错" }
Anthropic(content 是块数组,文字、图片、工具调用都在里面):
{
"role": "assistant",
"content": [
{ "type": "text", "text": "今天天气不错" },
{ "type": "tool_use", "id": "toolu_1", "name": "get_weather", "input": { "city": "北京" } }
]
}
创建客户端
官方 nuget 包地址:https://www.nuget.org/packages/Anthropic
AnthropicClient 默认从环境变量读取配置:
| 环境变量 | 说明 |
|---|---|
ANTHROPIC_API_KEY | API Key,请求头以 x-api-key 发送 |
ANTHROPIC_AUTH_TOKEN | Bearer Token,请求头以 Authorization: Bearer 发送 |
ANTHROPIC_BASE_URL | 服务地址,默认 https://api.anthropic.com |
最省事的用法是不传任何参数,SDK 会自动读取 ANTHROPIC_API_KEY / ANTHROPIC_BASE_URL 等环境变量。
using Anthropic;
using Anthropic.Models.Messages;
AnthropicClient client = new();
如果想显式指定 API Key、自定义网关地址(比如走本地代理或第三方兼容服务),就传一个 ClientOptions(注意类型名是 Anthropic.Core.ClientOptions,没有 AnthropicClientOptions):
AnthropicClient client = new(new ClientOptions
{
ApiKey = "sk-ant-xxx",
BaseUrl = "http://127.0.0.1:1234",
HttpClient = new HttpClient(new LoggingHandler()),
});
MessageCreateParams parameters = new()
{
MaxTokens = 1024,
System = "你是一个数学计算器",
Messages =
[
new()
{
Role = Role.User,
Content = "1+1=?",
},
],
Model = "qwen/qwen3.5-9b",
};
var response = await client.Messages.Create(parameters);
foreach (ContentBlock block in response.Content)
{
if (block.TryPickText(out TextBlock? text))
{
Console.WriteLine(text.Text);
}
}
ClientOptions 还有几个常用项:HttpClient / Handlers(自定义 HTTP 处理管道,可用于打日志)、MaxRetries(默认 2)、Timeout(默认 10 分钟)、ResponseValidation(是否校验响应)。
请求参数
一次请求由 MessageCreateParams 描述,几个核心字段:
| 参数 | 类型 | 说明 |
|---|---|---|
Model | string/Model 枚举 | 模型名,如 claude-sonnet-5。支持从 string 隐式转换 |
Messages | IReadOnlyList<MessageParam> | 必填,对话历史,角色只能是 user/assistant |
MaxTokens | long | 必填,输出 token 上限 |
System | string 或 List<TextBlockParam> | 系统提示,顶层字段,不在 Messages 里 |
Temperature | double? | 采样温度(较新模型上已废弃) |
TopP / TopK | double? / long? | 核采样参数(较新模型上已废弃) |
StopSequences | IReadOnlyList<string> | 自定义停止字符串 |
Thinking | ThinkingConfigParam | 扩展思考(深度思考)配置 |
Tools | IReadOnlyList<ToolUnion> | 工具定义 |
ToolChoice | ToolChoice | 工具选择策略(Auto/Any/Tool/None) |
Model在 SDK 里其实是个枚举(Model.ClaudeSonnet5、Model.ClaudeOpus4_6、Model.ClaudeHaiku4_5等),但因为属性类型ApiEnum<string, Model>提供了从string的隐式转换,所以直接写字符串"claude-sonnet-5"也行,对接非官方兼容服务时更灵活。
最简单的请求:
MessageCreateParams parameters = new()
{
MaxTokens = 1024,
Model = "claude-sonnet-5",
Messages =
[
new() { Role = Role.User, Content = "1+1=?" },
],
};
Message message = await client.Messages.Create(parameters);
注意 MessageParam.Content 是个联合类型,既能直接给字符串,也能给一个 ContentBlockParam 列表(用来混排文字、图片、工具结果),SDK 都提供了从 string 的隐式转换,所以日常纯文本对话写起来很简洁。
非流式对话
Anthropic 的非流式对话很简单。
AnthropicClient client = new(new ClientOptions
{
ApiKey = "sk-ant-xxx",
BaseUrl = "http://127.0.0.1:1234",
HttpClient = new HttpClient(new LoggingHandler()),
});
MessageCreateParams parameters = new()
{
MaxTokens = 1024,
System = "你是知识百科全书",
Messages =
[
new()
{
Role = Role.User,
Content = "太阳系有多少颗行星?"
},
],
Model = "qwen/qwen3.5-9b",
};
var response = await client.Messages.Create(parameters);
foreach (ContentBlock block in response.Content)
{
if (block.TryPickText(out TextBlock? text))
{
Console.WriteLine(text.Text);
}
}
实际请求内容:
{
"max_tokens": 1024,
"system": "你是知识百科全书",
"messages": [{
"role": "user",
"content": "太阳系有多少颗行星?"
}],
"model": "qwen/qwen3.5-9b"
}
响应结果:
{
"id": "msg_y1q7enj0g9lurvq8yn676m",
"type": "message",
"role": "assistant",
"content": [
{
"type": "text",
"text": "截至目前,国际天文学联合会(IAU)官方认定的太阳系行星数量为 **8 颗**。\n\n这八大行星按照距离太阳由近及远的顺序,依次是:\n\n1. **水星** (Mercury)\n2. **金星** (Venus)\n3. **地球** (Earth)\n4. **火星** (Mars)\n5. **木星** (Jupiter)\n6. **土星** (Saturn)\n7. **天王星** (Uranus)\n8. **海王星** (Neptune)\n\n**补充说明:**\n以前太阳系被认为有 9 颗行星,第 9 颗是“冥王星”。但在 2006 年,由于冥王星未能完全符合行星定义的三个标准(特别是它未能“清除其轨道附近的其他物体”),国际天文学联合会将其重新分类为“矮行星”,从而使得太阳系的正式行星数量定格为 8 颗。"
}
],
"model": "qwen/qwen3.5-9b",
"stop_reason": "end_turn",
"stop_sequence": null,
"usage": {
"input_tokens": 25,
"output_tokens": 212,
"cache_read_input_tokens": 0
}
}
返回的 Message 几个常用属性:
Content:IReadOnlyList<ContentBlock>,模型这一轮生成的所有内容块。正文文本在TextBlock里,要靠TryPickText取。StopReason:ApiEnum<string, StopReason>?,结束原因,取值EndTurn(end_turn,正常说完)、ToolUse(tool_use,要求调用工具)、MaxTokens(max_tokens,达到 token 上限)、StopSequence(stop_sequence,命中停止串)、PauseTurn(pause_turn,暂停)、Refusal(refusal,拒绝回答)。读它的值用message.StopReason?.Value。Usage:token 用量。InputTokens/OutputTokens是非空long,此外还有CacheCreationInputTokens/CacheReadInputTokens(提示缓存相关)。ID:这条消息的 id(如msg_01...)。Role/Type:固定为"assistant"/"message"。
读取正文文本:
foreach (ContentBlock block in response.Content)
{
if (block.TryPickText(out TextBlock? text))
{
Console.WriteLine(text.Text);
}
}
// 或者一次性把所有文本块拼起来
string answer = string.Join("",
response.Content
.Select(b => b.Value)
.OfType<TextBlock>()
.Select(t => t.Text));
Console.WriteLine(answer);
多轮对话
Anthropic 的 Messages 接口是无状态的(这点跟 OpenAI Responses 的 previousResponseId 不同),每一轮都要把完整历史自己拼好再发上去。做法跟 OpenAI Chat Completions 一样,把上一轮模型的 Content 整体作为一条 assistant 消息加回 messages,再追加新的 user 消息。
List<MessageParam> messages =
[
new() { Role = Role.User, Content = "1+1=?" },
];
MessageCreateParams parameters = new()
{
MaxTokens = 1024,
Model = "qwen/qwen3.5-9b",
Messages = messages,
};
// 第一轮
Message first = await client.Messages.Create(parameters);
if (first.Content[0].TryPickText(out TextBlock? firstText))
{
Console.WriteLine($"[ASSISTANT]: {firstText.Text}");
}
messages.Add(new MessageParam
{
Role = Role.Assistant,
Content = new MessageParamContent(
JsonSerializer.SerializeToElement(first.Content.Select(b => b.Json).ToArray())),
});
// 第二轮
messages.Add(new() { Role = Role.User, Content = "再加上 2 呢" });
parameters = new()
{
MaxTokens = 1024,
Model = "qwen/qwen3.5-9b",
Messages = messages,
};
Message second = await client.Messages.Create(parameters);
if (second.Content[0].TryPickText(out TextBlock? secondText))
{
Console.WriteLine($"[ASSISTANT]: {secondText.Text}");
}
第二轮实际请求体:
{
"max_tokens": 1024,
"model": "qwen/qwen3.5-9b",
"messages": [{
"role": "user",
"content": "1+1=?"
}, {
"role": "assistant",
"content": [{
"type": "text",
"text": "1 + 1 = **2**"
}]
}, {
"role": "user",
"content": "再加上 2 呢"
}]
}
流式对话
流式是 Claude 接口的日常用法。关键区别前面说过:不是设 stream:true,而是调另一个方法 client.Messages.CreateStreaming(...),它返回 IAsyncEnumerable<RawMessageStreamEvent>,用 await foreach 逐条消费。
MessageCreateParams parameters = new()
{
MaxTokens = 2048,
Model = "qwen/qwen3.5-9b",
Messages =
[
new() { Role = Role.User, Content = "讲一个关于写狼外婆的小故事" },
],
};
IAsyncEnumerable<RawMessageStreamEvent> updates =
client.Messages.CreateStreaming(parameters);
Console.Write("[助手] ");
await foreach (RawMessageStreamEvent rawEvent in updates)
{
if (rawEvent.TryPickContentBlockDelta(out var delta) && delta.Delta.TryPickText(out var text))
{
Console.Write(text.Text);
}
}
实际请求:
{
"max_tokens": 2048,
"model": "qwen/qwen3.5-9b",
"messages": [{
"role": "user",
"content": "讲一个关于写狼外婆的小故事"
}],
"stream": true
}
Anthropic 的 SSE 事件是按事件类型分发的:每条流式消息对应 RawMessageStreamEvent 的一个变体,TryPick 一次就知道它代表什么。常用的事件类型:
| 事件类型 | TryPick 方法 | 作用 |
|---|---|---|
message_start | TryPickStart | 流开始,.Message 是初始的 Message(含响应 id、Usage 的输入 token 等,此时 StopReason 还为空) |
content_block_start | TryPickContentBlockStart | 一个新的内容块开始(正文文本块、思考块、工具调用块等),.Index 是块下标 |
content_block_delta | TryPickContentBlockDelta | 增量就在这里。.Delta 又是一个联合,按 TryPickText / TryPickThinking / TryPickInputJson 等区分 |
content_block_stop | TryPickContentBlockStop | 一个内容块结束 |
message_delta | TryPickDelta | 整条消息级别的更新,.Delta 带 StopReason,.Usage 带输出 token 累计 |
message_stop | TryPickStop | 整个响应结束 |
也就是说,「逐字输出」在 content_block_delta 里,而且增量本身还要再 TryPick 一次。
正文文本和深度思考分别走两条路径:
await foreach (RawMessageStreamEvent rawEvent in updates)
{
if (rawEvent.TryPickContentBlockDelta(out var delta))
{
if (delta.Delta.TryPickThinking(out var thinkingDelta))
{
// 深度思考增量
Console.Write(thinkingDelta.Thinking);
}
else if (delta.Delta.TryPickText(out var textDelta))
{
// 正文增量
Console.Write(textDelta.Text);
}
}
}
跟 Chat Completions 手写 JSON 去捞 reasoning_content对比一下,Anthropic 这里是 SDK 原生支持的,扩展思考是一等公民,不需要自己解析原始 JSON。
如果你想一边流式输出、一边最后拿到一个完整的
Message,SDK 在Anthropic.Helpers里提供了Aggregate()扩展方法,或者用Anthropic.Services.Messages.MessageContentAggregator边收边聚合。
扩展思考
Claude 支持把深度思考过程暴露出来,模型在作答前先输出一段思考,再输出正文。开启方式是设置 Thinking:
MessageCreateParams parameters = new()
{
MaxTokens = 2048,
Model = "qwen/qwen3.5-9b",
Thinking = new ThinkingConfigEnabled() { BudgetTokens = 1024 },
Messages =
[
new() { Role = Role.User, Content = "证明根号 2 是无理数" },
],
};
Message response = await client.Messages.Create(parameters);
foreach (ContentBlock block in response.Content)
{
if (block.TryPickThinking(out ThinkingBlock? thinking))
{
Console.WriteLine($"[思考] {thinking.Thinking}");
}
else if (block.TryPickText(out TextBlock? text))
{
Console.WriteLine($"[正文] {text.Text}");
}
}
实际请求:
{
"max_tokens": 2048,
"model": "qwen/qwen3.5-9b",
"thinking": {
"type": "enabled",
"budget_tokens": 1024
},
"messages": [{
"role": "user",
"content": "证明根号 2 是无理数"
}]
}
几个要点:
ThinkingConfigEnabled.BudgetTokens是思考的 token 预算,必须 ≥ 1024 且小于MaxTokens,否则请求会被拒。- 非流式响应里,
Content数组会先出现ThinkingBlock(带Thinking文本和一个用于多轮续接的Signature),再出现TextBlock。 - 流式时,思考增量走
delta.Delta.TryPickThinking,正文增量走TryPickText,前面流式那段已经演示过。 - 如果思考内容被安全过滤,会出现
RedactedThinkingBlock(TryPickRedactedThinking),里面不暴露原文。 - 多轮对话里若要保留思考上下文,回填
assistant消息时要带上ThinkingBlock(直接用前面 JSON 往返的回填方式即可,整个Content原样塞回去最省事)。
提交工具与调用
我们沿用 OpenAI 那篇的灯控例子。先定义本地函数:
static Dictionary<int, bool> Lights = new()
{
{ 1, false }, { 2, false }, { 3, false }
};
static IReadOnlyDictionary<int, bool> GetLightState() => Lights;
static IReadOnlyDictionary<int, bool> OpenOrCloseLight(int index, bool state)
{
Lights[index] = state;
return GetLightState();
}
定义工具用 Tool + InputSchema。注意 Anthropic 的 schema 是直接平铺的(name/description/input_schema),没有 OpenAI 那层 function 嵌套;InputSchema.Properties 是个 Dictionary<string, JsonElement>,每个属性的 schema 用 JsonSerializer.SerializeToElement 构造:
using System.Text.Json;
Tool getLightState = new()
{
Name = "GetLightState",
Description = "获取所有灯的状态",
InputSchema = new InputSchema(),
};
Tool openOrCloseLight = new()
{
Name = "OpenOrCloseLight",
Description = "打开或关闭灯",
InputSchema = new InputSchema
{
Properties = new Dictionary<string, JsonElement>
{
["index"] = JsonSerializer.SerializeToElement(
new { type = "integer", description = "light index" }),
["state"] = JsonSerializer.SerializeToElement(
new { type = "boolean", description = "open or close light" }),
},
Required = ["index", "state"],
},
};
完整的工具调用循环:
static async Task Main()
{
Tool getLightState = new()
{
Name = "GetLightState",
Description = "获取所有灯的状态",
InputSchema = new InputSchema(),
};
Tool openOrCloseLight = new()
{
Name = "OpenOrCloseLight",
Description = "打开或关闭灯",
InputSchema = new InputSchema
{
Properties = new Dictionary<string, JsonElement>
{
["index"] = JsonSerializer.SerializeToElement(
new { type = "integer", description = "light index" }),
["state"] = JsonSerializer.SerializeToElement(
new { type = "boolean", description = "open or close light" }),
},
Required = ["index", "state"],
},
};
AnthropicClient client = new(new ClientOptions
{
ApiKey = "sk-ant-xxx",
BaseUrl = "http://127.0.0.1:1234",
HttpClient = new HttpClient(new LoggingHandler()),
});
List<MessageParam> messages =
[
new() { Role = Role.User, Content = "获取所有灯的状态,并把 1、3 号的灯打开" }
];
bool requiresAction;
do
{
requiresAction = false;
MessageCreateParams parameters = new()
{
MaxTokens = 2048,
Model = "qwen/qwen3.5-9b",
Thinking = new ThinkingConfigEnabled() { BudgetTokens = 1024 },
Messages = messages,
Tools = [getLightState, openOrCloseLight],
};
Message response = await client.Messages.Create(parameters);
// 1) 把这一轮 assistant 的完整输出(含 tool_use 块)原样回填进历史
messages.Add(new MessageParam
{
Role = Role.Assistant,
Content = new MessageParamContent(
JsonSerializer.SerializeToElement(response.Content.Select(b => b.Json).ToArray())),
});
// 2) 扫出所有 tool_use 块,本地执行,收集成 tool_result
var toolResults = new List<ToolResultBlockParam>();
foreach (ContentBlock block in response.Content)
{
if (!block.TryPickToolUse(out ToolUseBlock? toolUse))
{
continue;
}
switch (toolUse.Name)
{
case nameof(GetLightState):
toolResults.Add(new ToolResultBlockParam(toolUse.ID)
{
Content = JsonSerializer.Serialize(GetLightState()),
});
break;
case nameof(OpenOrCloseLight):
int index = toolUse.Input["index"].GetInt32();
bool state = toolUse.Input["state"].GetBoolean();
toolResults.Add(new ToolResultBlockParam(toolUse.ID)
{
Content = JsonSerializer.Serialize(OpenOrCloseLight(index, state)),
});
break;
default:
throw new NotImplementedException(toolUse.Name);
}
}
// 3) 只要有工具被调用,就把结果作为一条 user 消息回填,再循环
if (toolResults.Count > 0)
{
messages.Add(new MessageParam
{
Role = Role.User,
Content = new MessageParamContent(toolResults
.Select(r => (ContentBlockParam)r).ToList()),
});
requiresAction = true;
}
} while (requiresAction);
// 最后打印 assistant 的正文
foreach (MessageParam m in messages)
{
Console.WriteLine($"[{m.Role}] {m.Content}");
}
}
实际请求:
{
"max_tokens": 2048,
"model": "qwen/qwen3.5-9b",
"thinking": {
"type": "enabled",
"budget_tokens": 1024
},
"messages": [{
"role": "user",
"content": "获取所有灯的状态,并把 1、3 号的灯打开"
}],
"tools": [{
"name": "GetLightState",
"description": "获取所有灯的状态",
"input_schema": {
"type": "object"
}
}, {
"name": "OpenOrCloseLight",
"description": "打开或关闭灯",
"input_schema": {
"type": "object",
"properties": {
"index": {
"type": "integer",
"description": "light index"
},
"state": {
"type": "boolean",
"description": "open or close light"
}
},
"required": ["index", "state"]
}
}]
}
第一轮响应结果:
{
"id": "msg_1eq59rgu4hmkuywxccmspi",
"type": "message",
"role": "assistant",
"content": [
{
"type": "thinking",
"thinking": "用户想要:\n1. 获取所有灯的状态\n2. 把第1号和第3号灯打开\n\n我需要先调用GetLightState来获取所有灯的状态,然后调用OpenOrCloseLight两次,分别打开第1和第3号灯(state=true表示打开)。\n\n让我按顺序执行这些操作。\n"
},
{
"type": "tool_use",
"id": "YH9doFoUfvWaonXqLwRwrV2IR2dLO8ZG",
"name": "GetLightState",
"input": {}
}
],
"model": "qwen/qwen3.5-9b",
"stop_reason": "tool_use",
"stop_sequence": null,
"usage": {
"input_tokens": 352,
"output_tokens": 85,
"cache_read_input_tokens": 348
}
}
模型要调用工具时,响应的 StopReason 是 ToolUse,Content 里会有一条 tool_use 块:
{
"type": "tool_use",
"id": "YH9doFoUfvWaonXqLwRwrV2IR2dLO8ZG",
"name": "GetLightState"
}
你执行完,构造一条 user 消息回填,里面的 tool_result 块靠 tool_use_id 跟上面配对:
{
"messages": [
...
{
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": "YH9doFoUfvWaonXqLwRwrV2IR2dLO8ZG",
"content": "{"1":false,"2":false,"3":false}"
}]
}],
"tools": [{
"name": "GetLightState",
"description": "获取所有灯的状态",
"input_schema": {
"type": "object"
}
}, {
"name": "OpenOrCloseLight",
"description": "打开或关闭灯",
"input_schema": {
"type": "object",
"properties": {
"index": {
"type": "integer",
"description": "light index"
},
"state": {
"type": "boolean",
"description": "open or close light"
}
},
"required": ["index", "state"]
}
}]
}
上传图片
和 OpenAI 一样,附图的原理是把 user 消息的 content 从字符串换成内容块数组,混排文字和图片。Anthropic 支持两种图片来源:base64 内嵌(Base64ImageSource)和公开 URL(UrlImageSource)。
byte[] imageBytes = File.ReadAllBytes("34e7caa2-2852-458d-96cc-babff3c65c03.png");
string base64 = Convert.ToBase64String(imageBytes);
MessageCreateParams parameters = new()
{
MaxTokens = 1024,
Model = "qwen/qwen3.5-9b",
Messages =
[
new MessageParam
{
Role = Role.User,
Content = new List<ContentBlockParam>
{
new ImageBlockParam
{
Source = new Base64ImageSource
{
Data = base64,
MediaType = MediaType.ImagePng,
},
},
new TextBlockParam("识别图片内容。"),
},
},
],
};
Message response = await client.Messages.Create(parameters);
if (response.Content[0].TryPickText(out TextBlock? text))
{
Console.WriteLine($"[ASSISTANT]: {text.Text}");
}
实际请求体(图片以 image 块出现,source.type 为 base64,注意 Anthropic 的 data 是裸 base64,不带 data:image/png;base64, 前缀,这点跟 OpenAI 的 data: URI 不同):
{
"max_tokens": 1024,
"model": "qwen/qwen3.5-9b",
"messages": [{
"role": "user",
"content": [{
"type": "image",
"source": {
"type": "base64",
"data": "iVBORw0KGg1qSkxS7L...",
"media_type": "image/png"
}
}, {
"type": "text",
"text": "识别图片内容。"
}]
}]
}
注意:
MediaType是枚举,可选ImageJpeg/ImagePng/ImageGif/ImageWebP,要和真实图片格式一致。- base64 是裸数据。不要拼
data:image/png;base64,前缀,那会当成 base64 内容的一部分导致解码失败。 - 想用公开 URL,把
Source换成new UrlImageSource { Url = "https://..." }即可(source.type变成"url")。 - 跟 OpenAI 一样,混合内容块的顺序无所谓,但通常文字提示放图片后面,让模型先「看到」再回答。