这个周末小宝终于没球赛了,我也不用开车来回奔波两小时,再在寒风中瑟瑟发抖两小时(赛前训练+比赛)看球。本来打算做个应用尝试结合语音和 chat completion 中的 tools 做个智能客服,结果rust下一个好用的openai sdk都没有,于是干脆心一横,周六边写边录了7个视频(前后大概 6-7 小时),也算是为了一碟醋,包了顿饺子。后来有朋友提醒可以用 async-openai(有 700 多 star),不过木已成舟,也就算了。编辑视频的时候看了看 async-openai 的代码,实现思路跟我类似,但很多处理的选择不那么好,比如
reqwest::Client
其实
Clone
起来非常轻量,但它大量使用带生命周期的 Client,增加没必要的复杂性。此外没有充分利用 reqwest 生态,不管是 retry 还是 multipart 的处理,都写了很多不必要的代码。
不管怎样,自己写一遍 OpenAI API 的 SDK,还是有很多收获的。首先,进一步理解了 OpenAI 的 API,也吐槽了一些 API 参数设计不合理的地方;其次,对 serde,尤其是 serde 对 enum 的各种场景的使用,有了更深刻的了解;最后,就是终于找到了最舒服的使用 chat completion with tools 的方法,比如我只需要为 tools 有关的代码使用特定的使用 JsonSchema 的数据结构:
struct GetWeatherArgs {
/// The city to get the weather for.
pub city: String,
/// the unit
pub unit: TemperatureUnit,
}
enum TemperatureUnit {
/// Celsius
Celsius,
/// Fahrenheit
Fahrenheit,
}
struct GetWeatherResponse {
temperature: f32,
unit: TemperatureUnit,
}
// dummy function
fn get_weather_forecast(args: GetWeatherArgs) -> GetWeatherResponse {
match args.unit {
TemperatureUnit::Celsius => GetWeatherResponse {
temperature: 22.2,
unit: TemperatureUnit::Celsius,
},
TemperatureUnit::Fahrenheit => GetWeatherResponse {
temperature: 72.0,
unit: TemperatureUnit::Fahrenheit,
},
}
}
然后我就可以在 ChatCompletionRequest 中很简单地引用它:
let messages = vec![
ChatCompletionMessage::new_system("I can choose the right function for you.", ""),
ChatCompletionMessage::new_user("What is the weather like in Boston?", "user1"),
];
let tools = vec![
Tool::new_function::(
"get_weather_forecast",
"Get the weather forecast for a city.",
),
Tool::new_function::(
"explain_mood",
"Explain the meaning of the given mood.",
),
];
let req = ChatCompletionRequest::new_with_tools(messages, tools);
let res = SDK.chat_completion(req).await?;
assert_eq!(res.model, ChatCompleteModel::Gpt3Turbo);
assert_eq!(res.object, "chat.completion");
assert_eq!(res.choices.len(), 1);
let choice = &res.choices[0];
assert_eq!(choice.finish_reason, FinishReason::ToolCalls);
assert_eq!(choice.index, 0);
assert_eq!(choice.message.content, None);
assert_eq!(choice.message.tool_calls.len(), 1);
let tool_call = &choice.message.tool_calls[0];
assert_eq!(tool_call.function.name, "get_weather_forecast");
get_weather_forecast(serde_json::from_str(&tool_call.function.arguments)?);
使用者不需要自己撰写复杂的关于参数的 json schema。
编写边录了大半天,最终写下了大概 1.2k 行 Rust 代码,录了7个视频:
视频这周每天都发一个,一周就把它发完。
饺子包完了,终于轮到那碟醋 —— 智能客服。周日一大早,我开了个新的项目,叫 ava-bot,也是边录边写。不过周日活动比较多,所以断断续续写了大概4-5小时,录了5个视频。第一个视频探讨了设计思路:
这个思路在实际执行时稍有偏差,比如
mpsc::Channel
最终换成了
broadcast::Channel
。这种通过 Channel 在两个路由间传输数据的方式还是很漂亮的:执行时间很长的
/assistant
路由不断把中间状态发送到 Channel 里,而使用支持 SSE(Server-Side Event)的路由
/chats
和
/signals
不断从 Channel 里拿数据,渲染成 HTML 后,以 SSE 发给客户端,客户端最终通过 HTMX SSE 组件自动进行更新。
这个实现我觉得最优美的地方是使用 enum + template + From trait + helper function 使得冗长的数据结构到渲染 html 的过程变得非常简单清晰:
#[derive(Debug, Clone, Serialize, Deserialize, Template)]
#[template(path = "signal.html.j2")]
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
enum AssistantEvent {
Processing(AssistantStep),
Finish(AssistantStep),
Error(String),
Complete,
}
#[derive(Debug, Clone, Serialize, Deserialize, EnumString, Display)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
enum AssistantStep {
UploadAudio,
Transcription,
ChatCompletion,
Speech,
}
impl From for String {
fn from(event: AssistantEvent) -> Self {
event.render().unwrap()
}
}
fn in_transcription() -> String {
AssistantEvent::Processing(AssistantStep::Transcription).into()
}
...
用于渲染
AssistantEvent
的
signal.html.j2
:
{% match self %}
{% when AssistantEvent::Processing with (v) %}
<p class="text-gray-800"><i class="fa-solid fa-spinner animate-spin">i> Processing {{ v }}p>
{% when AssistantEvent::Finish with (v) %}
<p class="text-green-800">Finished {{ v }}p>
{% when AssistantEvent::Error with (v) %}
<p class="text-red-500"><i class="fa-solid fa-circle-exclamation">i>Error: {{ v }}p>
{% when AssistantEvent::Complete %}
<p class="text-green-800"><i class="fa-solid fa-check">i>Completep>
{% else %}
<p class="text-yellow-700">Unknown eventp>
{% endmatch %}
这使得在
/assitant
路由中发送状态的代码变得非常优美: