使用C#从零开发Agent系列教程 第二课:理解LLM API的格式,并使用C#程序发送LLM API请求

Agent

1. 使用OpenAI SDK调用DeepSeek API

上一节课我们用bruno发送HTTP请求,体验了一把什么叫tool/function,以及Agent的整体逻辑。

这一节课,我们就不用bruno了,我们首先,要用C#写一个程序,给DeepSeek发送http请求。第一步,就是创建一个console application。先确保你安装了dotnet sdk,然后在powershell中执行如下命令:

> dotnet new console -o DummyAgent --use-program-main

以上命令的细节我就不过多解释了,对C#或dotnet core不熟悉的同学,请去网页版问deepseek,多少发挥一点主观能动性。简单来说,以上命令就是创建了一个C#版的命令行Hello World程序。

接下来我们要发送http请求了,当然我们可以使用标准库的HttpClient来手写,但多少有点没必要,我们可以使用OpenAI SDK。听起来有点奇怪是吧?明明调的是DeepSeek,这里有OpenAI什么事?其实是因为DeepSeek的API是兼容OpenAI的,而OpenAI官方提供了一个library,能帮助我们方便的向LLM发送Http请求。进入项目目录,使用如下命令添加一个名为OpenAI的NuGet包。

> cd DummyAgent
DummyAgent> dotnet add package OpenAI

这里有个小tips:如果下载nuget package的速度过慢,可以添加华为的nuget镜像源,这样在国内的网络环境下会快很多。

DummyAgent> dotnet nuget add source "https://repo.huaweicloud.com/repository/nuget/v3/index.json" -n "huaweicloud"
Package source with Name: huaweicloud added successfully.
DummyAgent> dotnet nuget list source
Registered Sources:
  1.  nuget.org [Enabled]
      https://api.nuget.org/v3/index.json
  2.  huaweicloud [Enabled]
      https://repo.huaweicloud.com/repository/nuget/v3/index.json
  3.  Microsoft Visual Studio Offline Packages [Enabled]
      C:\Program Files (x86)\Microsoft SDKs\NuGetPackages\

以如上为例,我们总共注册了三个nuget源,但它们的顺序并不代表优先级,nuget的工作原理是会向所有源发送请求,然后使用第一个响应的源。如果你觉得官方源实在太慢,费事,可以手动把官方源disable掉,使用如下命令:

DummyAgent> dotnet nuget disable source "nuget.org" 
Package source with Name: nuget.org disabled successfully.
DummyAgent> dotnet nuget list source
Registered Sources:
  1.  nuget.org [Disabled]
      https://api.nuget.org/v3/index.json
  2.  huaweicloud [Enabled]
      https://repo.huaweicloud.com/repository/nuget/v3/index.json
  3.  Microsoft Visual Studio Offline Packages [Enabled]
      C:\Program Files (x86)\Microsoft SDKs\NuGetPackages\

接下来我们写Program.cs,代码如下,有适当的注释帮你理解:

using OpenAI;
using OpenAI.Chat;

namespace DummyAgent;

public static class Program
{
    public static void Main(string[] args)
    {
        // 输出提示语
        Console.WriteLine("想对AI说点什么?");
        Console.Write("> ");

        // 从键盘读取用户输入的一行字符串
        // 然后把用户的输入组装成http请求里的messages数组中的一个message
        // 这个message的role是user,所以有专用类 UserChatMessage
        string userInput = Console.ReadLine()!;
        UserChatMessage userInputMessage = new UserChatMessage(userInput);

        // 初始化客户端对象
        ChatClient client = InitializeDeepSeekClient();

        // 发送请求
        ChatCompletion completion = client.CompleteChat(userInputMessage);

        // 打印API响应中的信息
        for(int i = 0; i < completion.Content.Count; ++i)
        {
            Console.WriteLine($"=====响应体中第的{i+1}条消息=====");
            Console.WriteLine($"  {completion.Content[i].Text}");
        }
    }

    // 辅助函数:从环境变量读取DeepSeek API KEY
    public static string GetDeepSeekApiKey() => Environment.GetEnvironmentVariable("DEEPSEEK_APIKEY")!;

    // 辅助函数:初始化ChatClient对象,这个对象就是我们发送HTTP请求的帮手
    public static ChatClient InitializeDeepSeekClient() => new ChatClient(
        // 使用flash模型,更便宜
        model: "deepseek-flash",
        // 使用环境变量里存储的api key,其实就是设置了http头的Authorization字段
        credential: new System.ClientModel.ApiKeyCredential(GetDeepSeekApiKey()),
        // 设置base url为deepseek,而不是真正的open ai
        options: new OpenAIClientOptions
        {
            Endpoint = new Uri("https://api.deepseek.com")
        }
    );
}

编译,运行,运行效果如下:

send_request

ok,现在我们已经迈出了万里长征的第一步,在更进一步之前,我们需要补充一些知识。

2. chat completion API的请求体格式

首先需要知道的是,各大AI厂商其实提供了很多API供开发者去调用,我们整个系列教程使用的这套API,是历史最长,基本成了半个事实标准的,由Open AI定义出来的,叫 “chat completions api”。

顾名思义,它就是用来让AI做对话补全的,所谓的对话补全,意思就是:“你把历史聊天记录,和最新一个提问给我,我给你补出一个回答出来”。

这套API的HTTP请求的基本结构如下所示:

{
  "model": "deepseek-flash",
  "messages": [...],
  "thinking": "enabled",
  "reasoning_effort": "high",
  "max_tokens": 8192,
  "response_format": "text",
  "tools": [...],
  "tool_choice": "auto",
  "user_id": "abcdefj"
}

以上并没有列全所有的请求体中的一级字段,这里简单的过一下这些字段:

2.1 必要字段:model和messages

请求体中,只有两个字段是必填的:一是model,一是messages,这也很好理解:用户必须指定模型名称,这是天经地义的。并且用户必须提供“聊天记录+最新消息”,不然你让LLM去补个der?

需要多看一眼的,就是messages里的格式,它本身是个数组格式,我们要关注的则是数组中每个元素要写成什么样子。

简单来讲,“消息”只有四种,分别是:

  1. 天意爷,设定者的“设定消息”
  2. 用户发送的“用户消息”
  3. 模型吐出来的“模型消息”
  4. 书写工具执行结果的“工具消息”

天意爷的“设定消息”

这是一种特殊的消息,这种消息本身并不是“对话”的一部分。类比的话,这种消息一般有两个用途:

第一种用途:如果我们把使用“对话补全”和LLM API进行聊天,想象成是在监狱探视间里,小张和被关在监狱的LLM隔着玻璃进行对话的话(咱就假设LLM太克苏鲁了,管理局把它收容在了监狱)。

那么这种消息,就像是玻璃上的注意事项,比如:禁止搞黄色、不能提到西红柿等规则。

也就是说,如果我们需要给整个对话添加一些准则、规则之类的东西,我们可以在messages数组开头,放一个这种消息。

第二种用途:如果我们把使用“对话补全”和LLM API进行聊天,想象成是小张去少儿不宜的洗浴中心或者商K和LLM知心大姐姐聊天的话。

那么这种消息,就像是角色扮演游戏里的角色设定:如果小张喜欢学生妹,那么就可以在这里说“LLM你是一个16岁的学生妹,清纯的跟张柏芝一样”,或者小张喜欢御姐的话,那么就可以在这里说“你是一个高冷傲娇但口嫌体正直的长发御姐”。

总之,这种天意爷的“设定消息”,可以是设定“对话环境、准则”,也可以是设定“对方双方的角色背景”。在API请求中,这种消息长下面这样:

{
  "role": "system",
  "content": "你是一名23岁的长发大扔子高冷御姐,已经无可救药的爱上了我",
  "name": "角色设定天意爷"
}

这三个字段中

  1. role字段标示了这是一个“天意爷消息”。在deepseek中,天意爷的消息只能取system,在OpenAI中,除system外,还有一种天意爷,叫developer。我们不探究那么细节,我们在实践中只需要知道,用system就行了。
  2. content:它就是“设定消息”的“消息”本体,这个很符合直觉
  3. name: 这个则有点tricky,这是个选填字段,但不同的模型厂商对这玩意的理解还有些许偏差,我们可以暂时性的把它理解为:可以有多个天意爷,从不同角度对“对话”进行设定,比如如下:
"messages": [
  {
    "role": "system",
    "content": "你是一名23岁的长发大扔子高冷御姐,已经无可救药的爱上了我",
    "name": "角色设定天意爷"
  },
  {
    "role": "system",
    "content": "现在是2077年,我们的对话发生在物质极大丰富,但贫富差距极大的未来",
    "name": "背景时代设定天意爷"
  }
]

当然,在OpenAI的文档中,你还能找到对天意爷消息中的name字段有其它的解释和阐述,不过这不重要,在实践中我们一般都不写这个字段。

用户消息

这是用户发送的消息,很直白,很自然,没什么可说的,一个典型的用户消息长下面这样:

{
    "role": "user",
    "content": "房价在三年内,还有可能再涨一波吗?",
    "name": "小张"
}
  1. role字段的值为user,就代表这是一条用户消息
  2. content字段就是消息本体。值得注意的是,deepseek目前有多模态能力,所以对用户消息而言,content字段的值还可以是一个object[]数组,里面可以混杂文本、图片与文件。但我们这个系列教程只关注字符串文本。
  3. name字段就有意思了:这也是个选填字段。不提供这个字段的时候,deepseek会认为,这场对话是发生在“一个人”和“LLM”之间,但实际上我们可以通过name来区分多个不同的人类参与者,从而让对话从“一对一聊天”,变成“群聊”,不过所谓的“群聊”中,LLM只是其中的“一员”,每轮对话只会吐出一段话而已。典型的如下:
"messages": [
  {
    "role": "user",
    "content": "房价在三年内,还有可能再涨一波吗?",
    "name": "小张"
  },
  {
    "role": "user",
    "content": "我个人认为是不太可能了,我们用房价换经济发展的历史阶段已经过去了,所以无论是从市场角度还是政策角度来看,房价都没有理由再上涨了,你说是吧?",
    "name": "小王"
  }
]

模型吐出来的回复消息

注意这里的消息是“模型吐出来的回复消息”,但我们现在介绍的是“请求体的payload”,所以脑子不要乱:这种消息是LLM API调用者在请求体中回填历史聊天记录时写的。

它的结构也比较简单,如下:

{
  "role": "assistant",
  "content": "现在让我来帮你算这个算术题",
  "reasoning_content": "用户让我算23*23是多少,本来我能直接算的,但用户提供了计算器tool,所以我最好还是去调用这个tool去算结果",
  "tool_calls": [
    {
      "index": 0,
      "id": "call_00_sADDPPgg1gbpzlZwRr2F4493",
      "type": "function",
      "function": {
        "name": "calc",
        "arguments": "{\"表达式\":\"23*23\"}"
      }
    }
  ]
}
  1. role字段的值为assistant,代表这是一条模型吐出来的消息
  2. content字段就是模型吐出来的直接回复
  3. reasoning_content则是模型在思考模式下吐出来的碎碎念,等会我们会介绍到什么叫“思考模式”。
  4. tool_calls则是,如果用户提供了tool的时候,模型认为有必要调用某个tool时,就会在这里写上“调用哪个tool,以及参数是多少”。tool_calls是个数组,里面的每一个元素代表一次对tool的调用

API规范中其实还说了,在请求体中,还可以为模型的回复消息添加name字段,这个属于tricky玩法:虽然发送请求给LLM API时,LLM API在回复消息中并不会带上name字段,但开发者可以给这个回复赋予一个“角色名”,从而模拟出“与多个AI对话群聊”的感觉。

比如我们可以先并行发送10个请求,分别模拟班上十个同学分别去问AI一个问题,然后我们就可以把这十个人的聊天记录全部合并在一起,构造出一个大请求,再让AI统一分析这十个对话。那么在二次发送的大请求中,我们就要区分“小王的AI”和“小李的AI”,这就是name的用法。

目前暂时我们涉及不到这种用法。

再说说tool_calls

tool_calls数组中的每个对象,都代表“模型试图去调用一次tool”,这种对象的结构比较固定,大致长下面这样:

{
  "index": 0,
  "id": "call_00_aabbccdd",
  "type": "function",
  "function": {
    "name": "calc",
    "arguments": "{\"表达式\":\"23*23\"}"
  }
}
  1. index是个顺序号,当LLM API在一次回复中调用了多次tool时,这个值就是多次调用的先后顺序,从0开始
  2. id是这次调用tool的身份证号,接下来我们很快就会看到它的用途,简单来说,就是“模型声称需要调用某个tool”后,我们再次发送请求时就需要提供“调用结果”,为了让LLM API知道“哪个结果是哪次调用的”,就是使用这个身份证号来标识每次调用的。
  3. type的值基本固定为function。这是因为在OpenAI设计对话补全API时,在他们最早的设想中,有两层概念:工具/Tool是LLM API可以指挥的东西,而函数/Function只是其中一种“可指挥的东西”,但在实践中发现,几乎没有必要搞这两层抽象。所以目前来说,只是在API层面保持了这个设计,而这两层概念抽象实际上没有什么卵用。
  4. function是一个对象,里面分别用name写明了要调用的Tool的名字,用arguments写明了调用tool时的参数。比较别扭的是arguments的类型本身是个string,但其实是个json string,需要二次解析

调用工具的结果消息

这种消息,是Tool的调用结果,它的结构也比较固定,如下:

{
  "role": "tool",
  "content": "计算结果是529",
  "tool_call_id": "call_00_aabbccdd"
}
  1. role的值为tool,代表这是一条“工具调用结果消息”
  2. content中的内容,就是“工具调用的结果”。从函数角度来理解的话,这里的内容就是“返回值或抛出的异常”。这里思维要转换一下,如果你把Tool理解为类似程序设计语言中的“函数”的话,那么特殊的是,“工具调用结果”消息中的消息信息,在函数执行成功时,自然就是函数返回值 。但在函数执行失败时,它里面也要返回失败信息,也就是异常信息
  3. tool_call_id,是“工具调用”的身份证号。在上面的tool_calls数组中,模型一次性可能调用多个工具,甚至可能同一个工具被不同参数调用了多次,那么为了标示不同的调用的结果,就需要为每次调用设计一个“身份证号”:模型在回复的时候会生成tool_calls[i].id,我们在贴工具执行结果的时候,就要引用这个身份证号

2.2 可选字段:thinking和reasoning_effort

如今的LLM在回复对话的时候,会同时吐出两种内容:“答案”与“思考过程”,如下就是一个典型的例子:

我发送如下这样一个简单的请求:

{
  "model": "deepseek-flash",
  "messages": [
    {
      "role": "user",
      "content": "人生的容错率到底大不大?"
    }
  ]
}

得到了这样的一个回复:

{
  "id": "b8db5939-ec9f-402e-95de-25e3a490a64d",
  "object": "chat.completion",
  "created": 1792292491,
  "model": "deepseek-flash",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "**结论先说:人生的容错率不是“大”或“小”一个数,而是“总体偏大,但局部极小”。**  \n更准确地说:**大多数可逆错误,容错率比你想的大;少数不可逆或带复利后果的错误,容错率比你想的小。**\n\n可以把人生错误分成四类:\n\n1. **可逆、低成本错误:容错率很大**  \n   选错社团、换专业、第一份工作不合适、谈错恋爱、去错城市试一年。这些大多能改,损失主要是时间和情绪。年轻时尤其如此。\n\n2. **可逆、高成本错误:容错率中等**  \n   创业失败、转行、买房买错、gap year。能恢复,但会消耗现金流、机会窗口和心理资本。需要预留安全垫。\n\n3. **不可逆或高复利错误:容错率很小**  \n   严重健康损害、违法记录、成瘾、赌博/高杠杆爆仓、长期家暴、毁掉核心信用、在关键关系里反复伤害。这类错误不是“失败”,而是会改写底盘。\n\n4. **底线/生存型错误:几乎不能容错**  \n   危及生命、自由、基本健康、基本信用和人身安全的事,不能用“人生容错率高”来安慰自己。\n\n所以,**从“一次失败会不会毁掉一生”看,容错率通常很大。** 人生是重复博弈,不是一次高考定终身。多数人都会走弯路,后来也能修正。现代社会寿命长、路径多、学习机会多,整体容错率比历史上高。\n\n但从“随便折腾都能回到正轨”看,容错率并不大。因为时间、健康、信用、亲密关系、现金流都有复利效应。**错误本身不一定致命,致命的是在错误上加杠杆、拖太久、不设止损。**\n\n一个实用原则是:\n\n> **对可逆的事大胆试错,对不可逆的事极度保守;用底线思维保命,用成长思维试错。**\n\n具体做法:\n- 保住四条底线:健康、法律、信用、人身安全。\n- 保留 6—12 个月生活现金流,别让一次失败变成生存危机。\n- 重大选择先做“小规模试验”,不要一上来 all in。\n- 给错误设止损线和复盘机制。\n- 年轻时多试可逆错误,中年后更重视家庭责任和现金流安全。\n- 不要把“容错率高”当成赌博、负债、透支身体的理由。\n\n一句话:**人生总体容错率偏大,但前提是你别碰不可逆的雷,并且主动给自己造安全垫。容错率不是天生固定的,它可以被你的健康、现金流、技能、关系和底线意识做大。**",
        "reasoning_content": "我们需要回答中文问题:“人生的容错率到底大不大?” 需要哲学/现实建议。需要平衡观点,可能说没有统一答案,取决于维度/阶段/资源/风险不可逆性。要讨论容错率大不大:年轻时、健康、关系、财务等。核心:可逆错误容错大;不可逆/复利风险容错小。人生不是单次考试,长期看容错率比想象大,但关键节点和底线风险要谨慎。需要避免鸡汤。可以给框架:把错误分四类:可逆低成本、可逆高成本、不可逆、底线/生存。策略:提高容错率(健康、现金流、技能、关系、法律底线、保险、信息),避免致命错误。结论:总体容错率“中等偏大”,但非无限;容错空间可经营。要直接回答“到底大不大”:看定义。若指“一次失败是否毁掉一生”,通常不大(不是,容错大);若指“任意折腾都能回到正轨”,不大。用概率和恢复力视角。可提“人生是重复博弈,不是一锤定音;但有些局是一次性博弈”。给具体建议:20-30岁试错成本低,但注意学历/健康/法律/债务;中年容错受家庭责任限制;老年健康不可逆。还要提到比较:现代社会寿命长、职业路径多元、迁移/学习机会多,容错率历史上较高;但竞争、信息透明、住房医疗教育成本让某些错误代价高。最终回答:不是“大不大”而是“在哪些事上大、哪些小;你能否把它做大”。可给一句话:对可逆选择大胆,对不可逆选择保守;用底线思维保命,用成长思维试错。需要中文自然。可能用户想要深度。控制在适中长度。可以分点。要“到底”给判断。"
      },
      "logprobs": null,
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 38,
    "completion_tokens": 978,
    "total_tokens": 1016,
    "prompt_tokens_details": {
      "cached_tokens": 0
    },
    "completion_tokens_details": {
      "reasoning_tokens": 401
    },
    "prompt_cache_hit_tokens": 0,
    "prompt_cache_miss_tokens": 38
  },
  "system_fingerprint": "e72d3c7f663d3e1383b00b3884cb4501"
}

在回复中,很明显有两段内容,一段是choices[0].message.content,这就是对我们问题的正面回复。而另外一段内容则是choices[0].message.reasoning_content,它更像是模型在思考过程中的痕迹。

有点像人在回复问题的时候的心理活动,最早的时候LLM是没有这个心理活动的,现在基本都有了。需要注意的是,心理活动并不像表现上看到的,“把模型思考问题的过程”给我暴露了出来,或者换句话说,把“模型的思考过程”暴露出来并不是LLM API厂商的主要目的,这其实是个副作用。真正的feature是:模型从“直接吐出一个回答”,进化到了“先思考,再回答”。现在的模型有了“思考”这个过程。

而我们实际上可以使用请求参数来给模型一些暗示或者指挥。这里有两个参数,我们先说reasoning_effort,这个是OpenAI的标准API定义的参数,在OpenAI标准中,它有这么几个可选的值:none, minimal, low, medium, high, xhigh, max。和字面意思一样,这些值分别代表着“让模型以何种强度来进行思考”,最低的强度就是none,就是给模型说:“你不要想,直接按直觉给我回答”,最高强度则是max,就是给模型说:“你回答问题之前尽你最大努力好好思考,谨言慎行”。

不过嘛,在deepseek api中,没有分那么多层级,deepseek的api其实只分了四级强度,分别是none, low, high, max。为了兼容OpenAI的调用,deepseek把minimal映射成了low,把medium和xhigh都映射为也high。需要注意的是,默认情况下,deepseek的强度为high

那么当我们显式指定reasoning_effort == none时,构造如下这样的请求:

 {
   "model": "deepseek-flash",
+  "reasoning_effort": "none",
   "messages": [
     {
       "role": "user",
       "content": "人生的容错率到底大不大?"
     }
   ]
 }

这次我们得到的回复长下面这样

{
  "id": "b25323f2-8cf7-4afa-b3ae-0665d11ec320",
  "object": "chat.completion",
  "created": 1792293143,
  "model": "deepseek-flash",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "关于“人生的容错率到底大不大”,这个问题没有绝对的标准答案,因为它取决于**你用什么尺度去衡量**,以及**你处于人生的哪个阶段**。\n\n如果要把这个问题拆解开来,可以从以下几个维度来看:\n\n### 1. 从“生存底线”看:容错率极大\n如果把人生看作一场游戏,只要“血条”没空,游戏就可以继续。\n*   **时间的宽容:** 人生的跨度长达几十年。二十岁搞砸的事情,三十岁可以重来;三十岁跌入谷底,四十岁依然可以翻盘。相比于短跑,人生更像是一场马拉松,中途摔一跤、走一段弯路,对最终到达终点的影响并没有想象中那么大。\n*   **生存的韧性:** 绝大多数所谓的“致命错误”(如高考失利、失业、失恋、破产),在宏观的生命尺度下,都只是擦伤。人类的身体和心理有着极强的复原能力,只要人还在,就有无限修正的可能。\n*   **多元的赛道:** 社会不是单行道。没考上好大学可以学一门手艺,大厂裁员可以去做自由职业。人生的评价体系是多元的,此路不通,往往另有出路。\n\n### 2. 从“关键节点”看:容错率极小\n虽然人生很长,但决定走向的往往只有那么几步。\n*   **不可逆的选择:** 有些错误是不可逆的,比如触犯法律、透支健康、沾染恶习。这些事情的容错率几乎为零,一旦发生,人生的轨道会被强制改变。\n*   **时代的红利与周期:** 个人的努力固然重要,但也要考虑历史的进程。有些窗口期(如特定的行业风口、年龄门槛)一旦错过,重新入局的成本会呈指数级上升。\n*   **复利效应的中断:** 财富、技能、人际关系都需要时间积累。如果在关键期(如20-30岁)长期停滞或犯下大错,虽然不至于“死”,但可能会导致你比别人晚十年才达到同样的位置。\n\n### 3. 容错率的“弹性”取决于你的“安全垫”\n人生的容错率不是固定的,它像一个弹簧,你的“安全垫”越厚,容错率就越大:\n*   **物质安全垫:** 有存款、有资产的人,试错成本低,失败了可以重来;而背负巨额债务、手停口停的人,一次失业就可能引发连锁反应。\n*   **能力安全垫:** 拥有可迁移的核心技能、持续学习能力的人,容错率更高。因为换个环境、换个行业,他们依然能生存。\n*   **心理安全垫:** 逆商高、心态好、有支持系统(家人朋友)的人,能从失败中快速恢复。而陷入严重内耗、自我否定的人,一个小挫折就可能成为压垮骆驼的最后一根稻草。\n\n### 4. 最大的陷阱:把“容错率”当成“不作为”的借口\n很多人问这个问题,潜台词是:“我是不是可以随便折腾?”\n*   **容错率高,不等于可以反复作死。** 人生的容错率是给“尝试、探索、意外”准备的,而不是给“懒惰、逃避、赌博”准备的。\n*   **试错是有成本的。** 每一次犯错都会消耗你的时间、金钱和精力。虽然不至于致命,但会让你的人生走得更艰难。\n\n### 总结\n**人生的容错率,在“生死”和“时间”的尺度上极大,但在“关键选择”和“机会成本”的尺度上极小。**\n\n所以,最理性的态度是:**在战略上藐视它,在战术上重视它。**\n*   **战略上:** 告诉自己“大不了重头再来”,不要因为一次失败就否定整个人生,大胆去尝试。\n*   **战术上:** 敬畏规则,守住底线(健康、法律、道德),在关键节点上慎重决策,努力积累自己的“安全垫”。\n\n人生确实允许你犯错,但它不允许你一直犯错却不长记性。**容错率是留给那些摔倒后能爬起来、并且知道为什么摔倒的人的。**"
      },
      "logprobs": null,
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 12,
    "completion_tokens": 864,
    "total_tokens": 876,
    "prompt_tokens_details": {
      "cached_tokens": 0
    },
    "prompt_cache_hit_tokens": 0,
    "prompt_cache_miss_tokens": 12
  },
  "system_fingerprint": "e72d3c7f663d3e1383b00b3884cb4501"
}

可以看到,现在的choices[0].message对象中,不再有reasoning_content了

而当我们把reasoning_effort设置为max时,它的输出如下:

{
  "id": "8d298dba-56a7-4270-a455-fa3d5bdaeb4c",
  "object": "chat.completion",
  "created": 1792293332,
  "model": "deepseek-flash",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "结论先说:**人生的容错率不是一个固定值。它比焦虑叙事里的“一步错、步步错”要大,但远没有侥幸心理里的“什么都能重来”那么大。**  \n更准确地说:\n\n> **在底线之上、可逆、非加杠杆、能持续学习的事情上,人生容错率很大;在触碰底线、不可逆、会复利和押上全部筹码的事情上,容错率很小。**\n\n可以从几个层面看。\n\n**1. 从人生长度看,大多数错误不是终局**\n专业选错、工作走弯路、城市选错、失恋、考试失利、阶段性落后,通常都有修正空间。很多人二三十岁以为“完了”的事,四十岁回看只是一个岔路。人生不是一张只交一次、判一次分的试卷,更像一个长期在线系统:多数 bug 可以修,路线可以改。\n\n但注意:**能修正不等于没成本。** 时间、金钱、机会、信任、身体恢复力,都是成本。错得晚、错得大、负债重,容错率就会下降。\n\n**2. 容错率在人生中极不均匀**\n有些事错了,成本很低,甚至能变成经验;有些事一次就可能锁死。\n\n- 高容错:尝试新行业、换城市、学新技能、低成本创业、公开表达、短期试错、关系中的非原则性分歧。\n- 低容错:健康透支、严重违法、赌博和高杠杆负债、成瘾、危险驾驶、暴力、重大信任背叛、把全部积蓄押在一个不确定机会。\n- 会复利的错误:长期拖延、坏习惯、慢性债务、消耗性关系、信息闭塞、不愿复盘。它们单次看着小,但时间会放大。\n\n**3. 个体资源不同,容错率也不公平**\n家庭支持、身体健康、现金流、学历技能、社会关系、心理韧性,都会影响你“错得起几次”。同样一次失败,有人只是交学费,有人可能直接被推入困境。所以不能简单说“人生容错率很大”,因为对没有缓冲垫的人,一些错误确实很重。\n\n**4. 不要被幸存者偏差骗**\n你会听到“退学创业成功”“裸辞后逆袭”“all in 翻身”的故事,但失败者通常不会被传播。不要拿少数幸存者证明“随便赌都行”。同样,也不要被“一步没走好就永远完蛋”的恐惧绑架。两边都是叙事,不是概率本身。\n\n判断一件事的容错率,可以问自己五个问题:\n\n1. **最坏结果是什么?** 会危及生命、健康、自由、基本生存和巨额债务吗?\n2. **可逆吗?** 错了以后多久能撤回,成本多大?\n3. **会不会复利?** 是短期损失,还是会越长越大的坑?\n4. **是否押上全部筹码?** 有没有杠杆、担保、孤注一掷?\n5. **失败后还剩什么选择?** 我还有没有退路、技能、关系和现金流?\n\n一个很实用的原则是:**不要拿自己承受不起的损失,去赌看起来很大的收益。** 先小规模试验,保留退出权。\n\n想提高人生容错率,不是靠“相信未来”,而是靠结构:\n\n- 守住底线:健康、安全、法律、基本现金流;\n- 增加冗余:存款、可迁移技能、可信赖关系、备用方案;\n- 降低固定成本和负债,别轻易把自己锁死;\n- 用可逆的小实验代替孤注一掷;\n- 错了及时止损,不为了面子继续加码;\n- 把错误变成信息,而不是变成身份。\n\n所以,**人生的容错率大不大?**  \n对底线之上、可逆、有后备、肯复盘的人,它大到足以让你重新开始很多次;对突破底线、孤注一掷、无退路的人,它小到一次就够。\n\n与其问“人生能容几次错”,不如问:  \n**这次错会不会把我彻底锁死?我有没有保护好底牌?我能不能从错误里拿到信息,然后调整?**\n\n真正的智慧不是考验容错率的上限,而是抬高自己的下限。",
        "reasoning_content": "We need answer in Chinese. User asks philosophical: \"人生的容错率到底大不大?\" Need respond nuanced. Need maybe \"容错率\" metaphor. Need discuss depends on dimensions, time scale, stakes, resources, reversibility, learning. Need perhaps point out no single answer. \"人生很长,容错率比单次考试大;但关键节点、不可逆事故、结构性风险容错率小.\" Provide framework: distinguish reversible/irreversible, cost, frequency, compounding. Also note society narratives:高考 etc exaggerate single-choice. Modern life has many forks but time is nonrenewable. Need answer maybe reassuring but honest. Discuss \"大\" at macro level: most decisions can be corrected; not one exam determines all; identity can be rebuilt; age isn't strict. But \"not infinite\": health, legal, safety, irreversible violations, trust, some biological clocks; poverty reduces tolerance. Need maybe provide practical ways to increase容错率: avoid catastrophic irreversible bets, maintain health/basic finances/relations, diversify, keep learning, lower fixed costs, build transferable skills, find support networks, test small, obtain information, consider worst-case. Need perhaps mention survivorship bias: examples of dropouts successes fail to show failures. Also \"幸存者偏差\" and \"均值回归\"; don't use successful outlier as evidence. Need perhaps mathematical analogy expected value. Could define \"容错率\" as number of errors tolerable before goal failure. It depends on goal. If goal is \"being alive reasonably well\" huge; if goal is \"perfect path (top school, first love, one career)\" small. Changing goal changes it. Key question: not \"人生容错率大不大\" but \"在你的关键底线和可逆范围内,能否承受这次错误\". Also differentiate error types: exploratory errors/typos (cheap), catastrophic errors (drunk driving, gambling debt, illegal acts), compounding errors (procrastination, smoking), relational errors. Could give 2x2: reversible-irreversible, low-high stake, immediate-long-term. Advice: protect bottom line, increase options, reduce fixed liabilities.\n\nMaybe answer in Chinese with a clear thesis: \"总体看,比我们焦虑时想象的大,比侥幸心理期待的小;不是一个固定值,而是随选择和人生阶段变化,且可以被提高。\" Then sections:\n1. 人生不是一张只有一次判卷的试卷。大多数选择非一锤定音。偏差会在后续行动中修正。 \n2. 容错率是不均匀的: 考试/学校/职业初期 can switch; health、法律、债务、安全、亲密关系中的底线 often irreversible. Critical nodes matter, but fewer than common.\n3. 容错率受资源制约: family wealth, health, education, social network, age. Low resources lower.\n4. 幸存者偏差. \n5. 如何判断: Ask: worst case? reversible? can I afford? can I recover? does it compound? What options remain?\n6. How to enlarge: maintain base (health, cash reserve, relationships), avoid catastrophic bets, choose reversible experiments, keep skills transferable, not maximize local optimum, prepare Plan B, accept sunk cost.\n7. Conclusion: \"人生的容错率,在底线之上、非致命且可复利的方向上很大;在突破底线、不可逆、叠加杠杆和长期复利的方向上很小。聪明的活法是别考验它的上限,而是提高自己的下限。\"\n\nNeed maybe mention \"容错率大不大\" could be emotional. Maybe say if asking because failed exam/lost job/relationship/aged/returning: most can recover, but need concrete. However general user did not mention personal situation. We can keep general and mention if specific scene.\n\nNeed be careful not to be too deterministic. Speak compassionately. Maybe include a sentence \"人生不是程序测试,不是无限回滚;它更像长期在线系统: 多数 bug 可热修复,少数会导致宕机,且系统会留下数据.\" Nice.\n\nMaybe answer with a simple formula:\n人生的实际容错率 ≈ (可逆性 × 时间 × 后备资源) / (单次赌注的破坏性 × 复利风险 × 被迫程度)\nCould be intuitive. But ensure not oversimplify. Good.\n\nLet's craft answer moderate length. Desired oververbosity 5. Chinese. Possibly response in bullet points.\n\nLet's formulate:\n\n开头:\n\"结论先说:它不是固定值。总体比社交媒体渲染的‘一步错步步错’大,但远没有赌徒幻想的‘错了都能重来’那么大。更准确:底线以上、可逆、非加杠杆的事,容错率很高;触碰底线、不可逆、带复利的事,容错率很低。\"\n\nThen \"三个层次\":\n- 从人生长度看:大多数非致命错误有修正窗口。专业、工作、城市、伴侣可换;30/35/40不是一刀切. But age/time costs.\n- 从结构看:容错率不均匀。考试/名校/第一份工作被高估? maybe not universally. They matter but not absolute. Health、安全、法律、债务、成瘾、重大信任 are hard to reverse. Also key \"重大错误\" not just choices but failure to choose? \n- 从个体资源看:家庭、健康、现金流、技能、支持网络决定可恢复力. 容错不是公平。\n\nThen \"为什么会被误判\":\n- 幸存者偏差:只看到退学成功的,没看到失败者.\n- 叙事简化: \"一考定终身\"\"一步慢步步慢\"放大焦虑.\n- 反幸存者偏差: low bottom line can destroy.\n\nThen \"判断一件事的容错率的问题\":\n1. 最坏结果是什么?能活着/健康/无巨大债务/不违法吗?\n2. 可逆吗?多久能撤回?成本?\n3. 会不会复利?如债务、成瘾、坏关系、拖延.\n4. 是否押上全部筹码? 杠杆? \n5. 如果失败,还剩哪些选项? \nRule: 不拿不可承受的损失赌可观的收益; 先小规模试点.\n\nThen \"怎么提高\":\n- 守住底线(健康、法律、安全、基本现金流、不赌博不毒)\n- 增加冗余(存款、可迁移技能、关系网络、Plan B)\n- 降低固定成本与负债,保留退出权\n- 用可逆实验代替孤注一掷,小成本试错\n- 及时止损,不向沉没成本要尊严\n- 复盘把错误变信息\n\nConclusion:\n\"所以,人生的容错率大不大?对底线之上、愿意学习并有后备的人来说,大得足以让你重新开始几次;对突破底线、把所有筹码压上且无退路的人来说,小到一次就够。与其问人生能容几次错,不如问:这次错会不会把我彻底锁死?我有没有把底牌保护好?\"\n\nNeed maybe address if no rebound: \"允许自己错,但不要让自己无路可退.\" Good.\n\nNeed maybe include \"如果具体困境...\". Could end: \"如果你正面对某个具体选择/失败,可以告诉我,判断更具体.\" Keep helpful.\n\nMaybe include analogy \"人生像开放世界游戏,不死档; 不能回滚但可重开新路线.\" Nice but optional.\n\nNeed answer directly, not overly verbose. Let's produce. "
      },
      "logprobs": null,
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 38,
    "completion_tokens": 2432,
    "total_tokens": 2470,
    "prompt_tokens_details": {
      "cached_tokens": 0
    },
    "completion_tokens_details": {
      "reasoning_tokens": 1547
    },
    "prompt_cache_hit_tokens": 0,
    "prompt_cache_miss_tokens": 38
  },
  "system_fingerprint": "e72d3c7f663d3e1383b00b3884cb4501"
}

reasoning_content确实长的离谱,确实是往脑子冒烟的方向去思考的。

除了reasoning_content这个OpenAI标准参数外,deepseek还有一个类似的参数thinking,可以控制“思考模式的开启或关闭”。携带这个参数的请求大致长下面这样:

 {
   "model": "deepseek-flash",
   "reasoning_effort": "max",
+  "thinking": {
+    "type": "disabled"
+  },
   "messages": [
     {
       "role": "user",
       "content": "人生的容错率到底大不大?"
     }
   ]
 }

它有好几个比较别扭的地方:

  1. thinking的值必须是个对象,然后通过对象里的type字段来表示思考模式的开启与关闭,type的可选值只有enabled和disabled两种,默认情况下是enabled
  2. thinking目前按DeepSeek官方文档的说法,只能通过type来控制思考模式的开与关,并不能调整思考强度
  3. 当thinking.type == disabled的时候,思考模式就关了,这时你再在reasoning_effort里写任何强度都不生效了

2.3 可选字段:控制模型吐出结果长度的max_token字段

max_tokens字段在请求体中可以控制模型吐出信息的最大长度,这里有两个点需要注意:

  1. max_tokens的单位不是字符,也不是单词,而是token个数,你可以简单的把它理解为“英文单词数x1.33”,以及“中文字符x0.67"。这是一个非常不严谨的估算模型,并且随着LLM的演化,这个系数会发生变化,但总体上,基本可以估算为“三个英文单词是四个token”,以及“五个中文字符是三个token”
  2. max_tokens计的是“所有模型输出的总tokens数”,这里包括reasoning_content和content,也包括tool_calls。并且是先计reasoning_content,再计content,再计tool_calls
  3. 模型实际想吐出的结果如果长于max_tokens的时候,API的行为非常粗暴,就是直接截断。所以如果这个值过小,导致reasoning_content都被截断的时候,content直接就是个空串。甚至有时候tool_calls被截断
  4. reasoning_content和content的tokens数可以用我们上面的系数进行估算,但tool_calls计的是里面的arguments的长度。
  5. max_tokens的默认值,取值范围等,deepseek官方文档这边写的非常混乱,按官方文档的说法:
    1. 取值范围是1~393216,也就是最大384k
    2. 默认值与当前是否开启思考模式,以及思考强度有关:关闭思考模式,默认值为8192,开启思考模式并且强度为high及以下时,默认值为64k,思考强度设置为max时,默认值为128k

下面是一个生动的例子,只在告诉你,不要以为API只会截断reasoning_content和content,它也会截断tool_calls里的argument

tool_calls_been_truncated

2.4 可选字段:response_format

这个字段是一个强制保险,它用起来长下面这样:

 {
   "model": "deepseek-flash",
+  "response_format": {
+    "type": "text"
+  },
   "messages": [
     {
       "role": "user",
       "content": "人生的容错率到底大不大?用json格式回答我"
     }
   ]
 }

它跟thinking一样,值必须是个对象,但对象里只有个type用来写真正的值,可选值只有text和json_object两种。从语义上讲,它的功能是控制模型的输出到底是文本格式还是json格式,并且只控制content,不控制reasoning_content。

它默认值是text,但是,它tricky的地方在于:如果你要用json_object的话,你必须在请求的提示词里,显式的写上json字样,就像下面这样:

 {
   "model": "deepseek-flash",
   "response_format": {
+    "type": "json_object"
   },
   "messages": [
     {
       "role": "user",
+      "content": "人生的容错率到底大不大?用json格式回答我"
     }
   ]
 }

这样吐出来的choices[0].message.content就是字符串化后的json对象。如下图所示:

response_format

但如果你的提示词里完全没有提到json字样,接口就会报错,比如你发这样一个请求:

 {
   "model": "deepseek-flash",
   "response_format": {
     "type": "json_object"
   },
   "messages": [
     {
       "role": "user",
-      "content": "人生的容错率到底大不大?用json格式回答我"
+      "content": "人生的容错率到底大不大?"
     }
   ]
 }

你就会得到这样一个接口报错,响应码是400 Bad request:

{
  "error": {
    "message": "Prompt must contain the word 'json' in some form to use 'response_format' of type 'json_object'. (request_id: 41dc5bb0-2568-46b5-a177-2fd979bac1aa)",
    "type": "invalid_request_error",
    "param": null,
    "code": "invalid_request_error"
  }
}

不过需要注意的是,即使不设置response_format.type == json_object,只在提示词里用自然语言写,模式大多数时候也能给我吐出正确的json,如下:

response_format_02

而response_format.type == json_object显式设置了后,模型会严格承诺你,输出的一定是标准json格式,可以直接用程序解析的那种。

2.5 可选字段tools

tools的功能上篇文章我们已经体验过了,它里面描述的就是那些“可以让LLM去调用的函数/工具”,它是一个数组,数组中的每个元素的格式如下:

{
  "type": "function",
  "function": {
    "name": "xiao_wang",
    "description": "小王可以去菜市场购买东西,包括肉蛋奶葱姜蒜油盐酱醋等常见食材与调料,以及各种烹饪工具与锅碗瓢盆",
    "parameters": {
      "type": "object",
      "properties": {
        "物品": {"type": "string", "description": "要购买的物品"},
        "注意事项": {"type": "string", "description": "在采购时额外的注意事项"}
      },
      "required": ["物品"]
    }
  }
}
  1. type的值是固定值function,

    我们上面提到过,OpenAI最早在做设计时,在设想中,是把“工具”和“函数”做成两层抽象的,学过计算机网络原理的同学应该有体会,这有点像OSI网络模型抽象出来的“逻辑链路控制子层”与“介质访问控制子层”,设想是比较美好的。但在工程实践中发现,其实没必要分出两层,所以在实践中,虽然API在设计上还保持两层的结构,但实际上只有一种合法的Tool,就是Function,就像以太网在实践中,物理层只剩MAC层一样。

  2. function是一个json对象,内部以json格式描述着函数的细节

  3. function.name是函数名,需要注意的是,函数名的取名需要满足主流编程语言中函数名的要求:也就是不能使用中文,你得在起名时假想自己起的是一个C语言或者Java的函数名

  4. function.description是函数的说明。

    这个说明特别重要,开发者应该以自然语言详尽的在这里描述好这个函数的功能、边界。这里写的所有东西,是LLM决定“要不要调用这个函数”的唯一依据!因为在LLM的角度来看,它并看不到函数内部的实现是什么,也无法推断出这个函数到底是在做什么事情,它唯一能获取到的有关这个函数“功能”的地方,就是这里。

  5. function.parameters是函数的参数列表,这里比较反直觉的是,这个参数列表并不是数组类型,而是对象类型

    在内部,每个参数都是这个对象中的一个键-值对。其中键是参数名,值是一个嵌套对象,里面写两件事:

    • type,参数的类型
    • description,参数的附加说明
  6. required是一个数组,里面写着的是调用函数时,哪些参数是必选参数。像上例中,物品就是必选参数,而注意事项则是一个可选参数

其实从逻辑上来说,tools中的每个函数,我们其实描述的是“函数签名及文档”,那么自然的,模型能给我们吐出来的,也就是“调用哪个函数,以及调用时要传递的实际参数值”,但这里有个小坑需要注意:

  • 通常情况下,LLM会以json字符串的形式给我们吐出“调用这个函数时要传递的实际参数值”
  • 但在某些情况下,LLM抽风,或者受max_tokens影响,返回的“调用函数时要传递的实际参数值”可能并不是一个合法的json字符串

这在99%的情况下并不是什么大问题:我们只需要在下一轮对话中,把“参数格式错误,请检查参数”作为函数的返回值吐给LLM就可以了。不过需要提一嘴的是,OpenAI 的API规范中,给function对象设定了一个可选的字段叫strict,这个字段的默认值是false。而当你明确把这个字段设置为true的时候,模型就100%向你保证,它吐出来的“调用函数时要传递的实际参数值”一定是一个合法的json字符串。

目前DeepSeek有关这个strict的功能还处于Beta阶段,另外鉴于我上面也说了,这在实际Agent开发中并不算是一个特别大的问题,所以在本系列教程中,我们将不使用这个strict字段。

2.6 可选字段tool_choice

tool_choice则一个额外的可选字段,控制LLM如何使用我们提供的tools,它有两种形态, 第一种形态是以字符串做值,三个值的语义分别如下:

  1. none:强制要求模型不能调用任何Tool
  2. auto:让模型自行判断要不要调用,以及调用哪些Tool
  3. required: 强制要求模型在回复中,至少要调用一个Tool

这个字段的默认值是auto,非常容易理解。

它还有另外一种形态,以对象的形式,强制要求模型在回复中,必须调用,但只能调用一个指定的Tool,格式如下:

{
  "tool_choice": {
    "type": "function",
    "function": { "name" : "xiao_wang" }
  }
}

总的来说,在整个系列教程中,我们也几乎不会用到这个参数,知道一下就行了。

2.7 可选字段:user_id

本来这个字段都不值得说的,但我看网上有人对这个字段有误解。这里就简单说两句。

这是个调用者自定义的字符串,最大支持512个字符,虽然DeekSeek官方文档说了,这玩意可以用来区分业务侧的用户身份,但请注意:只用这玩意来区分业务侧的用户身份,在实践工程中是绝对不够用的。因为这玩意在回应体中,并不回填!

2.8 其它字段

我们上面只介绍了OpenAI规范下的LLM API最常用的,最重要的几个请求字段。有很多边角料的知识我们并没有涉及,掌握上面这些常用的字段,足够你理解Agent开发中对LLM API的调用了。

至于其余细节的、边角料的知识,受限于篇幅,也是考虑到实际作用并不大(坦白讲像tool_choice这种东西其实都没必要介绍),大家有需要的时候,再自己发挥主观能动性自己去查文档好了。

不过这里有一个特性值得提一下,就是目前,我们介绍的,与LLM API的交互方式,都是传统的HTTP Request Response方式,一来一回的。但如果大家使用过Claude Code或Copilot等产品的话,会发现AI的回复在客户端上展示的时候,是“流式”展示的:它是一个字一个字蹦出来的,像极了对面真的有个人在实时给你打字一样。这个效果其实并不是“客户端收到完整的Http Response后渲染出来的”动态效果,而是数据真的也是一个字一个字,一段话一段话以“Streaming”的方式接收的。

要实现这样的效果,你需要去研究OpenAI API请求体中的stream字段与stream_options字段,前者控制着LLM API是否以streaming的方式返回数据,后面则是更详细的控制选项。而在我们这个系列教程中,我们就不使用streaming方式了,就keep it simple一点,使用传统的http request + response一来一回就行了。

3. chat completion API的响应体格式

上面说了请求体长什么样子,下面来说响应体长什么样子,大体上,主要包含如下字段:

{
  "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
  "object": "chat.completion",
  "created": 1792297102,
  "model": "deepseek-flash",
  "choices": [...],
  "usage": {...},
  "system_fingerprint": "xxxxxxxxxxxxxxxxxxxxx"
}
  1. id是一个每次发送请求都会生成的Guid值,每次都会变。

    你可能觉得为什么要设计个id出来呢?http一发送一接收,你吐个ID给我,每次请求都会变,对我来说有什么用呢?但实际上这玩意不是给一问一答式的传统http请求用的,它是设计给streaming模式使用的。在streaming模式中,多个HTTP Response其实是同一个“请求-响应”的过程中的一个小片段,客户端需要用这个id来鉴别收到的信息到底是哪个请求的响应。

  2. object是一个固定值,值写死为chat.completion。但不要死脑筋啊,说它是“固定值”,只是说当我们调用对话补全API时,人家返回的是固定值“chat.completion”

  3. created是时间戳,是服务端在生成这个响应时的秒级unix时间戳

  4. model则是生成这个响应的模型名称

  5. system_fingerprint是一个比较晦涩的东西,我们也基本用不到,它是后台模型配置参数的指纹。

    它对我们唯一的作用,就是能让我们判断,LLM API内部有没有发生什么变动。典型的,同样的请求体,你发现,这周的调用返回,和上周的调用返回好像差别很大,已经跳出了“LLM虽然每次调用行为都不一样,但基本都比较收敛”这个范畴,你怀疑是不是DeepSeek背地里背着你升级了模型,或者调整了模型参数,或者什么其它服务端配置,那么这时你就可以比对一下上周请求里的system_fingerprint和这周的是否一样,如果不一样,就说明确实是服务端发生了变更。

    但具体变更是什么,梁圣肯定是不会告诉你的。

以上这些参数都不是重点,重点是两个:真正包含有用的LLM输出内容的choices数组,以及包含统计数据的usage对象,接下来挨个介绍它们:

3.1 包含模型输出信息的choices数组

首先要解释的就是这个字段的名字:明明是包含着模型输出信息的字段,为什么不叫replies或者model_response,而要叫choices呢?怪怪的,对吧?

答案其实很简单:因为OpenAI在最早做API设计的时候,设想的场景是,用户发来一段提示词,鉴于LLM生成的回复并不稳定,我们可以多生成几份,然后都返回回去,让客户端自己挑最合适的。语义就是“我给你多个选择,你自己挑你该用哪个”,所以这个单词就被定成了choices,然后跟着历史惯性一路沿用至今。

另外要解释的一个问题就是:明明模型吐出的只有一份答案,但为什么字段名是复数,并且字段值是数组类型?

一部分原因和上面起名叫choices一样,另外一部分原因是:OpenAI至今在客户端请求体中,还支持一个字段,用来控制“让模型生成几份答案”,至今你都可以去调用OpenAI官方的API,去让模型一次性吐出五份答案。而其它的LLM API厂商,典型的就比如DeepSeek,并不支持这个功能,但在API上又要兼容OpenAI,所以就成了这个别扭的样子。

在工程实践中,我们几乎可以假定choices是一个只包含一个元素的数组(当然有些极端场景下,choices可能是个空数组, 这可能在工程实践中需要特殊讨论),所以在工程实践中,在http响应体中解析模型回复的时候,直接写死response.choices[0]并没有你想象的那么糟糕。

而choices[0]的部分结构,则如下所示:

{
  "index": 0,
  "message": {
    "role": "assistant",
    "content": "...",
    "reasoning_content": "...",
    "tool_calls": [...]
  },
  "logprobs": null,
  "finish_reason": "stop"
}
  1. index是指,当choices里包含多个元素时,当前元素的索引值。所以结合上面的解释,很容易理解,这玩意的值基本是固定值0

  2. message是模型吐出信息的对象,我们上面介绍了请求体中的四种message,这里出来的就是“模型吐出来的回复消息”,这里不再赘述。

  3. lobprobs是一个我们无需关心的东西,它仅在请求体中包含logprobs=true时,才会是一个具体的对象,描述的是模型回复的“对数概率信息”。听不懂这是啥吧?听不懂就对了

  4. finish_reason描述的是:模型为什么在生成了reasoning_content和content后停止生成更多文本了,它可能出现的值包括:

    • stop: 最常见的值,代表模型自然停止了,话说完了
    • length:代表模型输出达到了上下文上限,或者达到了请求体中的max_tokens上限,所以停止了
    • content_filter:输出内容因触发发过滤策略而被砍了
    • tool_calls:模型自己觉得,确实没唠完,但唠不完的原因是需要先进行一些工具/函数调用。一般情况下这个值出现的时候,就代表message对象中存在tool_calls数组
    • insufficient_system_resource: 服务端炸了,脑血栓了,说不出话了
    • aborted:生成过程被打断了,注意,这里的“打断”,意思是“客户端主动放弃或取消”,而不是有人在服务端那边拉了电闸。听起来很奇怪是吧?因为这玩意主要出现在streaming模式交互中,典型的就是模型正哒哒哒吐字吐的正欢呢,客户端自己把闸拉掉了,就会收到这玩意。

鉴于我们在请求体格式中已经详细介绍过“模型吐出的消息”的格式了,所以这里就基本不需要再赘述了,特别是有关tool_calls数组内部长什么样子。

3.2 包含统计数据的usage对象

这个对象特别有用,是对本次请求+回应的数据总结:最大的作用,就是告诉调用者,你这次消耗了多少token,这个对象的基本结构如下所示:

{
    "prompt_tokens": 43,
    "completion_tokens": 987,
    "total_tokens": 1030,
    "prompt_tokens_details": {
      "cached_tokens": 0
    },
    "completion_tokens_details": {
      "reasoning_tokens": 464
    },
    "prompt_cache_hit_tokens": 0,
    "prompt_cache_miss_tokens": 43  
}

各字段也非常直观

  1. prompt_tokens: 请求中的提示词,消耗了多少“输入token”
  2. completion_tokens:响应中的模型回复,消耗了多少“输出token”
  3. total_tokens:上两者的和
  4. prompt_tokens_details.cached_tokens:消耗的“输入token`中有多少命令了缓存
  5. completion_tokens_details.reasoning_tokens: 消耗的“输出token`中,有多少是推理token
  6. prompt_cache_hit_tokens:和prompt_tokens_details.cached_tokens语义完全一样
  7. prompt_cache_miss_tokens:消耗的“输入token”中有多少未命中缓存

这里有点奇怪的就是:明明prompt_tokens加上prompt_tokens_details.cached_tokens就能说明白:输入多少token,有多少命中缓存,多少没有命中缓存。但为什么又出现两个prompt_cache_hit_tokens和prompt_cache_miss_tokens呢?有必要吗?

原因非常简单:因为prompt_tokens_details.cached_tokens是OpenAI标准规范里的字段,而prompt_cache_[hit/miss]_tokens则是DeepSeek自家的独家字段。

客户端一般使用这些数据来估算token消耗,这在Agent开发中也是非常重要的。开发者向LLM API厂商充值,获取 API KEY,然后按产品的token消耗账单付费,这没什么毛病。但开发者写的Agent应用,要面向下游千万个C端用户,你不可能为每一个独立客户端都分配一个独立的API KEY,然后在DeepSeek开放平台统计每个API KEY的用量,然后向C端客户收费吧?所以如果你做的Agent应用要进行计费,这就是计费的依据。

4. 在程序中把http请求的细节打印出来

现在回到我们最初写的程序上,由于OpenAI SDK封装了底层细节,我们能看到的只有ChatClient对象,UserChatMessage对象以及ChatCompletion对象,所以在代码中,我们直接看不到底层发送的Http请求和收到的Http响应到底长什么样子,现在我们写个小工具,来让ChatClient在每次发送请求的时候,都把这些细节打印出来。

首先是实现一个PipelinePolicy,如下(下面的代码如果看不懂,等会会解释):

using System.ClientModel;
using System.ClientModel.Primitives;
using System.Text;

namespace DummyAgent;

/// <summary>
/// 自定义管道策略:打印底层发出的 HTTP 请求和收到的 HTTP 响应的 payload。
/// 通过 <see cref="OpenAI.OpenAIClientOptions.AddPolicy"/> 注册后即可生效。
/// </summary>
public sealed class HttpLoggingPolicy : PipelinePolicy
{
    private readonly bool _logHeaders;

    public HttpLoggingPolicy(bool logHeaders = true) => _logHeaders = logHeaders;

    public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
    {
        LogRequest(message);

        // 让传输层把响应体完整缓冲到内存,这样我们记录完日志后 SDK 还能再读一次
        message.BufferResponse = true;

        try
        {
            ProcessNext(message, pipeline, currentIndex);
        }
        finally
        {
            // 即使发送过程中抛异常,只要响应已产生也尽量打印出来
            LogResponse(message);
        }
    }

    public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
    {
        LogRequest(message);

        message.BufferResponse = true;

        try
        {
            await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false);
        }
        finally
        {
            LogResponse(message);
        }
    }

    private void LogRequest(PipelineMessage message)
    {
        try
        {
            PipelineRequest request = message.Request;

            Console.WriteLine($"===== [HTTP Request] {request.Method} {request.Uri} =====");

            if (_logHeaders)
            {
                foreach (KeyValuePair<string, string> header in request.Headers)
                {
                    Console.WriteLine($"  {header.Key}: {header.Value}");
                }
            }

            if (request.Content is null)
            {
                return;
            }

            using MemoryStream buffer = new();
            request.Content.WriteTo(buffer, message.CancellationToken);
            byte[] body = buffer.ToArray();

            Console.WriteLine($"  Body: {Encoding.UTF8.GetString(body)}");

            // 原 content 是一次性的(写出去后流就空了),这里替换成可重复读取的副本,
            // 否则真正发送请求时 body 会变成空的
            request.Content = new BufferedBinaryContent(body, request.Content.MediaType);
        }
        catch (Exception ex)
        {
            Console.WriteLine($"[HTTP Request] 记录请求失败: {ex.Message}");
        }
    }

    private void LogResponse(PipelineMessage message)
    {
        try
        {
            PipelineResponse? response = message.Response;
            if (response is null)
            {
                return;
            }

            Console.WriteLine($"===== [HTTP Response] {response.Status} {response.ReasonPhrase} =====");

            if (_logHeaders)
            {
                foreach (KeyValuePair<string, string> header in response.Headers)
                {
                    Console.WriteLine($"  {header.Key}: {header.Value}");
                }
            }

            Console.WriteLine($"  Body: {response.Content}");
        }
        catch (Exception ex)
        {
            Console.WriteLine($"[HTTP Response] 记录响应失败: {ex.Message}");
        }
    }

    /// <summary>
    /// 把已经读进内存的字节重新包装成 <see cref="BinaryContent"/>,
    /// 保证可以被反复写入(重试时也能再次发送)。
    /// </summary>
    private sealed class BufferedBinaryContent : BinaryContent
    {
        private readonly byte[] _buffer;

        public BufferedBinaryContent(byte[] buffer, string? mediaType)
        {
            _buffer = buffer;
            MediaType = mediaType;
        }

        public override bool TryComputeLength(out long length)
        {
            length = _buffer.Length;
            return true;
        }

        public override void WriteTo(Stream stream, CancellationToken cancellationToken)
        {
            cancellationToken.ThrowIfCancellationRequested();
            stream.Write(_buffer, 0, _buffer.Length);
        }

        public override async Task WriteToAsync(Stream stream, CancellationToken cancellationToken)
        {
            await stream.WriteAsync(_buffer.AsMemory(), cancellationToken).ConfigureAwait(false);
        }

        public override void Dispose()
        {
            // 内容都在内存里,无需释放
        }
    }
}

然后修改一下我们的Program.cs,如下修改:

 using OpenAI;
 using OpenAI.Chat;
+using System.ClientModel.Primitives;
+
 namespace DummyAgent;
 
 public static class Program
 {
     public static void Main(string[] args)
     {
         // 输出提示语
         Console.WriteLine("想对AI说点什么?");
         Console.Write("> ");
 
         // 从键盘读取用户输入的一行字符串
         // 然后把用户的输入组装成http请求里的messages数组中的一个message
         // 这个message的role是user,所以有专用类 UserChatMessage
         string userInput = Console.ReadLine()!;
         UserChatMessage userInputMessage = new UserChatMessage(userInput);
 
         // 初始化客户端对象
         ChatClient client = InitializeDeepSeekClient();
 
         // 发送请求
         ChatCompletion completion = client.CompleteChat(userInputMessage);
 
         // 打印API响应中的信息
         for(int i = 0; i < completion.Content.Count; ++i)
         {
             Console.WriteLine($"=====响应体中第的{i+1}条消息=====");
             Console.WriteLine($"  {completion.Content[i].Text}");
         }
     }
 
     // 辅助函数:从环境变量读取DeepSeek API KEY
     public static string GetDeepSeekApiKey() => Environment.GetEnvironmentVariable("DEEPSEEK_APIKEY")!;
 
     // 辅助函数:初始化ChatClient对象,这个对象就是我们发送HTTP请求的帮手
-    public static ChatClient InitializeDeepSeekClient() => new ChatClient(
-        // 使用flash模型,更便宜
-        model: "deepseek-flash",
-        // 使用环境变量里存储的api key,其实就是设置了http头的Authorization字段
-        credential: new System.ClientModel.ApiKeyCredential(GetDeepSeekApiKey()),
-        // 设置base url为deepseek,而不是真正的open ai
-        options: new OpenAIClientOptions
-        {
-            Endpoint = new Uri("https://api.deepseek.com")
-        }
-    );
+    public static ChatClient InitializeDeepSeekClient()
+    {
+        OpenAIClientOptions Options = new ()
+        {
+            // 设置base url为deepseek,而不是真正的open ai
+            Endpoint = new Uri("https://api.deepseek.com")
+        };
+
+        // 把我们自定义实现的日志policy挂到管道上,请求发出前和返回后都会打印 payload
+        // 我们不打印http header,只打印payload
+        options.AddPolicy(new HttpLoggingPolicy(false), PipelinePosition.PerCall);
+
+        return new ChatClient(
+            // 使用flash模型,更便宜
+            model: "deepseek-flash",
+            // 使用环境变量里存储的api key,其实就是设置了http头的Authorization字段
+            credential: new System.ClientModel.ApiKeyCredential(GetDeepSeekApiKey()),
+            options: options
+        );
+    }
 }

重新运行程序,效果如下:

http_logging

请求体如下:

{
  "messages": [
    {
      "role": "user",
      "content": "给我讲个有关王楚钦的笑话"
    }
  ],
  "model": "deepseek-flash"
}

响应体如下

{
  "id": "e7b61cb1-d1e5-4fb8-8198-0db6917aeebd",
  "object": "chat.completion",
  "created": 1792391325,
  "model": "deepseek-flash",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "讲个轻松的:\n\n王楚钦比赛前,裁判提醒他:“你发带上好像写了个字。”\n王楚钦摸了摸:“没有啊。”\n裁判说:“我看见了,写着‘稳住’。”\n王楚钦淡定回答:“哦,那是给对手看的。”\n裁判问:“给对手看‘稳住’?”\n王楚钦:“对,提醒他稳住,别被我晃倒。”",
        "reasoning_content": "我们需要回答中文。用户要讲一个有关王楚钦的笑话。需要注意:不能造谣/侮辱性。可以轻松幽默。王楚钦是乒乓球运动员。需要笑话可能涉及他打球风格、头大? 常见梗“大头”。但注意不要人身攻击,轻松即可。可以讲一个温馨段子:王楚钦去便利店买水,老板说“你打球那么凶,瓶盖我帮你拧开吧”,他说不用,结果一拧开瓶盖飞了,老板:“果然是拧拉高手。” 这个谐音梗? “拧拉”乒乓球技术。可以。需要讲笑话。最好简短。避免冒犯。可以自嘲式:王楚钦教小朋友打球,小朋友问怎么才能像你一样帅,王楚钦说多练;小朋友说那怎么才能像你一样头大,王楚钦说:这得问教练。可能涉及头大,不太好吧。可以没有贬义。另一个笑话:王楚钦比赛前对镜子说“今天一定要稳住”,镜子回答“你先稳住你的发带”。这个轻松。或者“王楚钦去理发店,理发师问剪什么发型,他说‘随便,能戴发带就行’。” 不够笑话。\n\n需要讲一个有关王楚钦的笑话。可以编一个“王楚钦的球拍丢了,去广播室:请帮我找一下,一把黑色手柄、红色胶皮、上面写着‘别惹我’的球拍。工作人员问:为什么写别惹我?他说:因为那是我借马龙的。” 可能涉及马龙,幽默但没关系?需要不造谣。或者“王楚钦参加快问快答:你最怕对手说什么?他说: ‘你这球拧得不错。’ 主持人:这不是夸你吗?他说:问题是那球我没拧上。” 这是自嘲,乒乓球“拧拉”。可以。\n\n我们可以用简洁笑话。最好带点乒乓球梗:拧拉、发球、发带、大头。王楚钦外号“大头”,但注意不要外貌攻击。轻松说“头哥”是球迷称呼。讲一个:王楚钦去食堂打饭,阿姨问:“小伙子,要多少?” 他说:“少来点,我下午还要拧拉。” 阿姨手一抖给了满满一勺:“那你得多吃点,拧拉费脖子。” 这个不太好笑。\n\n或许 classic: “王楚钦比赛前系发带,系了半天,教练问:紧张吗?他说:不是,我在给大脑做赛前热身。” 这个涉及大头,但轻松。可以。\n\n需要确认:王楚钦是乒乓球运动员,发带是他的标志。笑话:教练问王楚钦:“你为什么总戴发带?” 王楚钦:“怕关键分的时候,脑子里的战术太多,流出来。” 教练:“那为啥不戴头盔?” 王楚钦:“戴头盔影响我拧拉。” 可以。\n\n要回答“给我讲个有关王楚钦的笑话”。直接讲一个。可以稍微铺垫。用中文。注意“笑话”不一定爆笑。要幽默。\n\n可以讲两个短笑话吗?用户说一个。一个即可。\n\n最终:可以写:\n王楚钦去便利店买矿泉水,老板认出他:“哟,大头,比赛那么猛,瓶盖我帮你拧吧?” 王楚钦摆手:“不用不用,我自己来。” 说完一拧,瓶盖“嗖”地飞出去。老板愣了两秒:“不愧是拧拉高手,开个瓶盖都带旋转。”\n这个不错,利用“拧拉”和“大头”称呼,但“大头”可能有冒犯?球迷常用,但为安全可以不用“大头”。改成“哟,小王”。避免外貌。最后“开瓶盖都带旋转”有点好笑。\n\n或者更简单:王楚钦去便利店,老板说:“你打球那么厉害,帮我拧一下这瓶盖?” 王楚钦一拧,没开。老板:“咦?” 王楚钦:“正常,我拧拉是拧乒乓球,不是拧瓶盖。” 不太搞笑。\n\n可以加入“发带”梗。笑话:\n记者问王楚钦:“你比赛前为什么要戴发带?”\n王楚钦:“为了防止汗水流到眼睛里。”\n记者:“那为什么你的发带总是那么显眼?”\n王楚钦:“因为汗水可以流,战术不能漏。”\n这个有点冷。\n\n需要让笑话有笑点。可以用对话反转:\n王楚钦去理发,理发师问:“剪短点?”\n王楚钦:“别太短,我明天要戴发带。”\n理发师:“那后脑勺呢?”\n王楚钦:“后脑勺也留点,关键分还得靠它冷静。”\n理发师:“原来发带是给脑子刹车的。”\n不太行。\n\n另一个:\n王楚钦训练时球打飞到场外,正好落进一位大爷的菜篮里。大爷看了看球,又看了看他:“小伙子,这球上怎么写着‘王楚钦’?” 王楚钦不好意思:“我的球都做记号。” 大爷:“那你这记号做得挺大,跟你的名气一样。” 王楚钦:“大爷,您别夸我,我脸都红了。” 大爷:“脸红啥,我说的是字大。” 这个有“头大”隐含?不好。\n\n考虑网络梗“王楚钦——大头”。如果用户是粉丝,可能接受。但作为AI,避免外貌。可以讲“发带”梗,比较安全,且是标志性。\n\n笑话:\n王楚钦比赛前在更衣室找东西,队友问:“找啥呢?”\n王楚钦:“发带。”\n队友:“不就在你头上吗?”\n王楚钦:“哦,那我再找找脑子,刚才想战术想丢了。”\n队友:“别找了,戴上发带就说明脑子在岗。”\n这个自嘲“脑子丢了”可能不太尊重。但轻松。\n\n更好的:王楚钦参加采访,主持人问:“如果比赛打到10平,你会想什么?” 王楚钦:“想发球。” 主持人:“那如果发球前呢?” 王楚钦:“想发带别松。” 主持人:“为什么?” 王楚钦:“因为脑子可以转,发带不能掉。” 冷。\n\n需要真正有笑点。可以讲一个双关:\n王楚钦去餐厅点了一份“爆炒拧拉”,服务员说没有。他说:“那就来个‘发球抢攻’。” 服务员:“也没有。” 王楚钦:“那你们到底有什么?” 服务员:“有‘稳稳的幸福’。” 王楚钦:“行,那给我来一份,少放辣,我下午还要打关键分。” 这个莫名其妙。\n\n可能更简单:\n“王楚钦最怕别人对他说什么?——‘你发带歪了。’ 因为他会下意识去扶,结果发现今天没戴发带,然后开始怀疑自己是不是没带脑子。” 这个有点。\n\n或者利用“王楚钦”名字和“头”:\n王楚钦走进一家帽子店,店员热情推荐:“这款帽子特别显头小。” 王楚钦转身就走。店员喊:“先生不满意吗?” 王楚钦:“我打球靠实力,不靠显头小。” 这个涉及外貌,不太好。\n\n考虑可以讲一个“谐音梗”:\n王楚钦去参加考试,监考老师问:“你叫什么?” “王楚钦。” 老师:“楚钦?那你‘出勤’了吗?签到表上没你。” 王楚钦:“我这不是来了吗?” 老师:“那你把发带摘了,我确认一下是不是本人。” 这个不好笑。\n\n笑话:王楚钦教马龙用新发带,马龙问:“这玩意儿有用吗?” 王楚钦:“有,戴上之后,对手看你发球都觉得你战术深不可测。” 马龙戴上照镜子:“确实,连我自己都不知道我在想什么。” 这个可以,涉及马龙,轻松。笑点在于发带增加神秘感。但可能不准确。\n\n或者:\n王楚钦打比赛,裁判提醒:“王楚钦,你发带上有字。” 王楚钦:“不可能啊。” 裁判:“真的,写着‘稳住’。 ” 王楚钦:“哦,那是我给对手看的。” 裁判:“给对手看‘稳住’?” 王楚钦:“意思是——你稳住,我要发力了。” 这个可以,幽默且不冒犯。\n\n我们可以用这个。需要“有关王楚钦的笑话”,直接讲:\n王楚钦有次比赛,裁判提醒他:“你发带上好像写了个字。” 王楚钦摸了摸:“没有啊。” 裁判说:“我看见了,写着‘稳住’。” 王楚钦淡定回答:“哦,那是给对手看的。” 裁判问:“给对手看‘稳住’?” 王楚钦:“对,提醒他稳住,别被我晃倒。” \n有点冷但可以。\n\n另一个更好笑:王楚钦去食堂,阿姨问:“要几两饭?” 王楚钦:“二两。” 阿姨:“你这么高这么壮,二两够吗?” 王楚钦:“够,我主要靠发带提供能量。” 阿姨:“那给我也来一条。” 这个无厘头。\n\n需要最终输出一个笑话,简短。可以说明“轻松版”。避免敏感。用“发带”梗。最终回答:\n\n讲个轻松的:\n王楚钦比赛前,裁判提醒他:“你发带上好像写了个字。” 王楚钦摸了摸:“没有啊。” 裁判说:“我看见了,写着‘稳住’。” 王楚钦淡定回答:“哦,那是给对手看的。” 裁判问:“给对手看‘稳住’?” 王楚钦:“对,提醒他稳住,别被我晃倒。” \n裁判:“那你自己呢?” 王楚钦:“我戴发带,主要是怕战术太多,从脑门溢出来。” \n这个可以。但“脑门溢出来”有点头大暗示。也可以删掉。\n\n最终简版。"
      },
      "logprobs": null,
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 39,
    "completion_tokens": 2179,
    "total_tokens": 2218,
    "prompt_tokens_details": {
      "cached_tokens": 0
    },
    "completion_tokens_details": {
      "reasoning_tokens": 2099
    },
    "prompt_cache_hit_tokens": 0,
    "prompt_cache_miss_tokens": 39
  },
  "system_fingerprint": "e72d3c7f663d3e1383b00b3884cb4501"
}

可以看到,OpenAI的SDK构造的请求还是非常干净的。接下来,我们对不熟悉PipelinePolicy的同学补充一点C#中的基础知识

4.1 HttpLoggingPolicy的工作原理

这里不做过多深入阐述,我们简单的把OpenAI SDK的实现方式理解为一个Pipeline,PipelinePolicy是System.ClientModel里的一个设计模式库,逻辑上和asp .net core的middleware pipeline比较像。

  • 在ChatClient的内部,它持有一个ClientPipeline对象,这个对象的内部由一串有序的Policy对象组成,每个Policy都执行一定的动作,最后一个Policy对象会真正调用HttpClient向外发送请求
  • 每个Policy对象都实现两个方法:一个同步的Process方法和一个异步的ProcessAsync方法,分别在上层调用同步和异步接口时被调用
  • Process和ProcessAsync中的主要参数功能如下:
    • message:内部包含Request对象和Response对象,是对一次Http请求的包装
    • pipeline:整个Policy数组
    • currentIndex: 当前的Policy对象在数组中的位置
  • 每个Policy对象自己并不保存一个Next指针指向下一个Policy对象,而是通过currentIndex+1找到下一个Policy对象的

在Program.cs中,我们向options中调用AddPolicy的时候,第二个参数决定了这个Policy对象的执行模式,其实内部就是决定了这个Policy对象在pipeline中的插入位置,分为三种:

  1. PerCall:上层每次调用一次,Policy对象就执行一次,内部如果有重试逻辑的话,无论重试几次,Policy的逻辑都执行一次
  2. PerTry:在PerCall的基础上,每次重试也会执行Policy的逻辑
  3. BeforeTransport:把Policy对象插入到离真实发送Http请求最近的那里,就是在发送请求之前执行

当我们自己构造了我们的HttpLoggingPolicy并以PerCall形式把它添加到pipeline中后,pipeline长下面这样:

0 GenericActionPipelinePolicy     (OpenAI自己实现的Policy)
1 HttpLoggingPolicy               (我们新添加的Policy)
2 ClientRetryPolicy               (负责失败重试的Policy)
3 ApiKeyAuthenticationPolicy      (向Http请求头添加认证字段的Policy)
4 MessageLoggingPolicy            (框架自带的日志Policy,紧挨着真实的Http请求发送行为)
5 HttpClientPipelineTransport     (这是最后一个Policy对象,负责实际调用HttpClient发送请求)

我们的HttpLoggingPolicy构造函数中有个参数,控制是否打印Http请求头,如果你把那个参数设置为True的话,你会发现,打印的Http请求头中,是不包含Authorization头的,看不到真实发送请求时的API KEY,背后的原因就是在请求发送时,Policy从0到5按顺序执行时,执行到HttpLoggingPolicy时,Http请求对象中还没有认证字段,认证字段是后续索引为3的ApiKeyAuthenticationPolicy负责添加的。

另外还有两个非常tricky的地方,就在这里:

public sealed class HttpLoggingPolicy : PipelinePolicy
{
    ...
    public override void Process(...)
    {
        ...
        message.BufferResponse = true;
        ...
    }
    ...
    private void LogRequest(PipelineMessage message)
    {
        ...
        request.Content = new BufferedBinaryContent(body, request.Content.MediaType);
        ...
    }
}

先说LogRequest里的代码:

代码是通过狸猫换太子的形式,把message.Reqeust.Content给替换成了一个我们自己构造的BufferedBinaryContent,这是因为在整个dotnet core的HTTP类库设计中,请求对象里的Content,在接口设计上,只要求实现方提供“至少一次读取”,但不强制要求实现方“能支持多次读取”。假设我们并不知道OpenAI SDK在这里是怎么实现Content的话,我们只能保守的假定,这个Content只支持一次性读取。(实际上是支持多次重复读取的,但我们不能冒这个险)

如果Content只支持一次性读取,阅后即焚,那如果我们直接在这里调用message.Request.Content.WriteTo把原message.Request.Content里的内容读出来,后续就再也读不到里面的内容了,这个设计存在于整个dotnet core与web有关的类库设计里。为了能让后续的Policy对象,特别是最后发送请求的HttpClientPipelineTransport对象能真正读到请求体,我们这里把它狸猫换太子,换成了一个可以重复读取的BufferedBinaryContent对象

再说Process里为什么要写message.BufferResponse = true:

简单来说,message.Response.Content的设计,是框架给了使用者一个开关,用来选择“是否要把所有http response payload全装进内存里”,这个逻辑背后的意思是:

  1. 如果你选择“不需要把所有http response payload”全装进内存里的话,那么你读message.Response.Content会抛异常,正确的读http response的方式是去message.Response.ContentStream里去读这个Stream,显然,这个Stream是阅后即焚的,只支持一次读取
  2. 如果你选择“确实需要把所有http response payload”全装进内存里的话,那么你拿到的message.Response.Content就会是一个支持反复读取的内存块。

总的来说,dotnet core在http类库设计里,都尽力把内存中保存请求体与响应体的数据结构做成一次性读取的,以避免在各个不同的层级保存多份数据副本。

再总的来说,这只是一些没用的知识,在前AI时代要搞明白这些弯弯绕绕可能真要费点时间,但现在有了AI了,这些边角料知识变得极其廉价了。

5. 小结

这一节依然没有真正上手开发我们的Agent,但至此,你应该完全理解了LLM API的运作模式,并且我们也简单的写了一个小程序,用C#把API请求发了出去。

虽然看起来并没有学到什么实际的知识,但你现在至少心里有了一些sense,知道什么叫user message, 什么叫assistant message,知道为什么choices叫choices,知道了什么是tool_calls,这为我们后续开发打下了坚实的基础,并且有了这些Sense之后,后续你再看C#里调用的OpenAI SDK的那些方法、函数,你一眼就能看明白,每个方法背后是在做什么东西。