Packages
langchain
0.3.2
0.9.2
0.9.1
0.9.0
0.8.14
0.8.13
0.8.12
0.8.11
0.8.10
0.8.9
0.8.8
0.8.7
0.8.6
0.8.5
0.8.4
0.8.3
0.8.2
0.8.1
0.8.0
0.7.0
0.6.3
0.6.2
0.6.1
0.6.0
0.5.2
0.5.1
0.5.0
0.4.1
0.4.0
0.4.0-rc.3
0.4.0-rc.2
0.4.0-rc.1
0.4.0-rc.0
0.3.3
0.3.2
0.3.1
0.3.0
0.3.0-rc.2
0.3.0-rc.1
0.3.0-rc.0
0.2.0
0.1.10
0.1.9
0.1.8
0.1.7
0.1.6
0.1.5
0.1.4
0.1.3
0.1.2
0.1.1
0.1.0
Elixir implementation of a LangChain style framework that lets Elixir projects integrate with and leverage LLMs.
Current section
Files
Jump to
Current section
Files
lib/chat_models/chat_bumblebee.ex
defmodule LangChain.ChatModels.ChatBumblebee do
@moduledoc """
Represents a chat model hosted by Bumblebee and accessed through an
`Nx.Serving`.
Many types of models can be hosted through Bumblebee, so this attempts to
represent the most common features and provide a single implementation where
possible.
For streaming responses, the Bumblebee serving must be configured with
`stream: true` and should include `stream_done: true` as well.
Example:
Bumblebee.Text.generation(model_info, tokenizer, generation_config,
# ...
stream: true,
stream_done: true
)
This supports a non streaming response as well, in which case, a completed
`LangChain.Message` is returned at the completion.
The `stream_done` option sends a final message to let us know when the stream
is complete and includes some token information.
The chat model can be created like this and provided to an LLMChain:
ChatBumblebee.new!(%{
serving: @serving_name,
template_format: @template_format,
receive_timeout: @receive_timeout,
stream: true
})
The `serving` is the module name of the `Nx.Serving` that is hosting the
model.
The following are the supported values for `template_format`. These are
provided by `LangChain.Utils.ChatTemplates`.
Chat models are trained against specific content formats for the messages.
Some models have no special concept of a system message. See the
`LangChain.Utils.ChatTemplates` documentation for specific format examples.
Using the wrong format with a model may result in poor performance or
hallucinations. It will not result in an error.
## Full example of chat through Bumblebee
Here's a full example of having a streaming conversation with Llama 2 through
Bumblebee.
defmodule MyApp.BumblebeeChat do
@doc false
alias LangChain.Message
alias LangChain.ChatModels.ChatBumblebee
alias LangChain.Chains.LLMChain
def run_chat do
# Used when streaming responses. The function fires as data is received.
callback_fn = fn
%LangChain.MessageDelta{} = delta ->
# write to the console as the response is streamed back
IO.write(delta.content)
%LangChain.Message{} = message ->
# inspect the fully finished message that was assembled from all the deltas
IO.inspect(message, label: "FULLY ASSEMBLED MESSAGE")
end
# create and run the chain
{:ok, _updated_chain, %Message{} = message} =
LLMChain.new!(%{
llm:
ChatBumblebee.new!(%{
serving: Llama2ChatModel,
template_format: :llama_2,
stream: true
}),
verbose: true
})
|> LLMChain.add_message(Message.new_system!("You are a helpful assistant."))
|> LLMChain.add_message(Message.new_user!("What is the capital of Taiwan? And share up to 5 interesting facts about the city."))
|> LLMChain.run(callback_fn: callback_fn)
# print the LLM's fully assembled answer
IO.puts("\\n\\n")
IO.puts(message.content)
IO.puts("\\n\\n")
end
end
Then run the code in IEx:
recompile; MyApp.BumblebeeChat.run_chat
"""
use Ecto.Schema
require Logger
import Ecto.Changeset
alias __MODULE__
alias LangChain.ChatModels.ChatModel
alias LangChain.Message
alias LangChain.Function
alias LangChain.TokenUsage
alias LangChain.LangChainError
alias LangChain.Utils
alias LangChain.MessageDelta
alias LangChain.Utils.ChatTemplates
alias LangChain.Callbacks
alias LangChain.Message.ToolCall
alias LangChain.Utils.Parser.LLAMA_3_1_CustomToolParser
alias LangChain.Utils.Parser.LLAMA_3_2_CustomToolParser
@behaviour ChatModel
@current_config_version 1
@primary_key false
embedded_schema do
# Name of the Nx.Serving to use when working with the LLM.
field :serving, :any, virtual: true
# # What sampling temperature to use, between 0 and 2. Higher values like 0.8
# # will make the output more random, while lower values like 0.2 will make it
# # more focused and deterministic.
# field :temperature, :float, default: 1.0
field :template_format, Ecto.Enum,
values: [
:inst,
:im_start,
:zephyr,
:phi_4,
:llama_2,
:llama_3,
:llama_3_1_json_tool_calling,
:llama_3_1_custom_tool_calling,
:llama_3_2_custom_tool_calling
]
# The bumblebee model may compile differently based on the stream true/false
# option on the serving. Therefore, streaming should be enabled on the
# serving and a stream option here can change the way data is received in
# code. - https://github.com/elixir-nx/bumblebee/issues/295
field :stream, :boolean, default: true
# Seed for randomizing behavior or giving more deterministic output. Helpful
# for testing.
field :seed, :integer, default: nil
# A list of maps for callback handlers (treat as private)
field :callbacks, {:array, :map}, default: []
end
@type t :: %ChatBumblebee{}
# @type call_response :: {:ok, Message.t() | [Message.t()]} | {:error, String.t()}
# @type callback_data ::
# {:ok, Message.t() | MessageDelta.t() | [Message.t() | MessageDelta.t()]}
# | {:error, String.t()}
@type callback_fn :: (Message.t() | MessageDelta.t() -> any())
@create_fields [
:serving,
# :temperature,
:seed,
:template_format,
:stream
]
@required_fields [:serving]
@doc """
Setup a ChatBumblebee client configuration.
"""
@spec new(attrs :: map()) :: {:ok, t} | {:error, Ecto.Changeset.t()}
def new(%{} = attrs \\ %{}) do
%ChatBumblebee{}
|> cast(attrs, @create_fields)
|> restore_serving_if_string()
|> common_validation()
|> apply_action(:insert)
end
@doc """
Setup a ChatBumblebee client configuration and return it or raise an error if invalid.
"""
@spec new!(attrs :: map()) :: t() | no_return()
def new!(attrs \\ %{}) do
case new(attrs) do
{:ok, chain} ->
chain
{:error, %Ecto.Changeset{} = changeset} ->
raise LangChainError.exception(changeset)
end
end
defp restore_serving_if_string(changeset) do
case get_field(changeset, :serving) do
value when is_binary(value) ->
case Utils.module_from_name(value) do
{:ok, module} ->
put_change(changeset, :serving, module)
{:error, reason} ->
add_error(changeset, :serving, reason)
end
_other ->
changeset
end
end
defp common_validation(changeset) do
changeset
|> validate_required(@required_fields)
end
@impl ChatModel
def call(model, prompt, functions \\ [])
def call(%ChatBumblebee{} = model, prompt, functions) when is_binary(prompt) do
messages = [
Message.new_system!(),
Message.new_user!(prompt)
]
call(model, messages, functions)
end
def call(%ChatBumblebee{} = model, messages, functions) when is_list(messages) do
try do
# make base api request and perform high-level success/failure checks
case do_serving_request(model, messages, functions) do
{:error, reason} ->
{:error, reason}
parsed_data ->
{:ok, parsed_data}
end
rescue
err in LangChainError ->
{:error, err}
end
end
@doc false
@spec do_serving_request(t(), [Message.t()], [Function.t()]) ::
list() | struct() | {:error, String.t()}
def do_serving_request(
%ChatBumblebee{template_format: :llama_3_1_json_tool_calling} = model,
messages,
functions
) do
prompt =
ChatTemplates.apply_chat_template_with_tools!(messages, model.template_format, functions)
model.serving
|> Nx.Serving.batched_run(%{text: prompt, seed: model.seed})
|> do_process_response(model)
end
def do_serving_request(
%ChatBumblebee{template_format: :llama_3_1_custom_tool_calling} = model,
messages,
functions
) do
prompt =
ChatTemplates.apply_chat_template_with_tools!(messages, model.template_format, functions)
model.serving
|> Nx.Serving.batched_run(%{text: prompt, seed: model.seed})
|> do_process_response(model)
end
def do_serving_request(
%ChatBumblebee{template_format: :llama_3_2_custom_tool_calling} = model,
messages,
functions
) do
prompt =
ChatTemplates.apply_chat_template_with_tools!(messages, model.template_format, functions)
model.serving
|> Nx.Serving.batched_run(%{text: prompt, seed: model.seed})
|> do_process_response(model)
end
def do_serving_request(%ChatBumblebee{} = model, messages, _functions) do
prompt = ChatTemplates.apply_chat_template!(messages, model.template_format)
model.serving
|> Nx.Serving.batched_run(%{text: prompt, seed: model.seed})
|> do_process_response(model)
end
def do_process_response(
%{results: [%{text: "[" <> _ = content, token_summary: token_summary}]},
%ChatBumblebee{template_format: :llama_3_2_custom_tool_calling} = model
)
when is_binary(content) do
if !Code.ensure_loaded?(NimbleParsec) do
raise "Install NimbleParsec to use custom tool calling"
end
fire_token_usage_callback(model, token_summary)
case LLAMA_3_2_CustomToolParser.parse(content) do
{:ok, functions} ->
case Message.new(%{
role: :assistant,
status: :complete,
content: content,
tool_calls:
Enum.with_index(functions, fn i,
%{
function_name: name,
parameters: parameters
} ->
ToolCall.new!(%{
call_id: Integer.to_string(i),
name: name,
arguments: parameters
})
end)
}) do
{:ok, message} ->
# execute the callback with the final message
Callbacks.fire(model.callbacks, :on_llm_new_message, [model, message])
# return a list of the complete message. As a list for compatibility.
[message]
{:error, changeset} ->
reason = Utils.changeset_error_to_string(changeset)
Logger.error("Failed to create non-streamed full message: #{inspect(reason)}")
{:error, reason}
end
{:error, _} ->
case Message.new(%{role: :assistant, status: :complete, content: content}) do
{:ok, message} ->
# execute the callback with the final message
Callbacks.fire(model.callbacks, :on_llm_new_message, [model, message])
# return a list of the complete message. As a list for compatibility.
[message]
{:error, changeset} ->
reason = Utils.changeset_error_to_string(changeset)
Logger.error("Failed to create non-streamed full message: #{inspect(reason)}")
{:error, reason}
end
end
end
def do_process_response(
%{results: [%{text: "<" <> _ = content, token_summary: token_summary}]},
%ChatBumblebee{template_format: :llama_3_1_custom_tool_calling} = model
)
when is_binary(content) do
if !Code.ensure_loaded?(NimbleParsec) do
raise "Install NimbleParsec to use custom tool calling"
end
fire_token_usage_callback(model, token_summary)
case LLAMA_3_1_CustomToolParser.parse(content) do
{:ok,
%{
function_name: name,
parameters: parameters
}} ->
case Message.new(%{
role: :assistant,
status: :complete,
content: content,
tool_calls: [ToolCall.new!(%{call_id: "test", name: name, arguments: parameters})]
}) do
{:ok, message} ->
# execute the callback with the final message
Callbacks.fire(model.callbacks, :on_llm_new_message, [model, message])
# return a list of the complete message. As a list for compatibility.
[message]
{:error, changeset} ->
reason = Utils.changeset_error_to_string(changeset)
Logger.error("Failed to create non-streamed full message: #{inspect(reason)}")
{:error, reason}
end
{:error, _} ->
case Message.new(%{role: :assistant, status: :complete, content: content}) do
{:ok, message} ->
# execute the callback with the final message
Callbacks.fire(model.callbacks, :on_llm_new_message, [model, message])
# return a list of the complete message. As a list for compatibility.
[message]
{:error, changeset} ->
reason = Utils.changeset_error_to_string(changeset)
Logger.error("Failed to create non-streamed full message: #{inspect(reason)}")
{:error, reason}
end
end
end
@doc false
def do_process_response(
%{results: [%{text: "{" <> _ = content, token_summary: token_summary}]},
%ChatBumblebee{template_format: :llama_3_1_json_tool_calling} = model
)
when is_binary(content) do
fire_token_usage_callback(model, token_summary)
case Jason.decode(content) do
{:ok,
%{
"name" => name,
"parameters" => parameters
}} ->
case Message.new(%{
role: :assistant,
status: :complete,
content: content,
tool_calls: [ToolCall.new!(%{call_id: "test", name: name, arguments: parameters})]
}) do
{:ok, message} ->
# execute the callback with the final message
Callbacks.fire(model.callbacks, :on_llm_new_message, [model, message])
# return a list of the complete message. As a list for compatibility.
[message]
{:error, changeset} ->
reason = Utils.changeset_error_to_string(changeset)
Logger.error("Failed to create non-streamed full message: #{inspect(reason)}")
{:error, reason}
end
{:error, _} ->
case Message.new(%{role: :assistant, status: :complete, content: content}) do
{:ok, message} ->
# execute the callback with the final message
Callbacks.fire(model.callbacks, :on_llm_new_message, [model, message])
# return a list of the complete message. As a list for compatibility.
[message]
{:error, changeset} ->
reason = Utils.changeset_error_to_string(changeset)
Logger.error("Failed to create non-streamed full message: #{inspect(reason)}")
{:error, reason}
end
end
end
def do_process_response(
%{results: [%{text: content, token_summary: token_summary}]},
%ChatBumblebee{} = model
)
when is_binary(content) do
fire_token_usage_callback(model, token_summary)
case Message.new(%{role: :assistant, status: :complete, content: content}) do
{:ok, message} ->
# execute the callback with the final message
Callbacks.fire(model.callbacks, :on_llm_new_message, [message])
# return a list of the complete message. As a list for compatibility.
[message]
{:error, %Ecto.Changeset{} = changeset} ->
reason = Utils.changeset_error_to_string(changeset)
Logger.error("Failed to create non-streamed full message: #{inspect(reason)}")
{:error, LangChainError.exception(changeset)}
end
end
def do_process_response(stream, %ChatBumblebee{stream: false} = model) do
# Request is to NOT stream. Consume the full stream and format the data as
# though it had not been streamed.
full_data =
Enum.reduce(stream, %{text: "", token_summary: nil}, fn
{:done, %{token_summary: token_data}}, %{text: text} ->
%{text: text, token_summary: token_data}
data, %{text: text} = acc ->
Map.put(acc, :text, text <> data)
end)
do_process_response(%{results: [full_data]}, model)
end
def do_process_response(stream, %ChatBumblebee{} = model) do
chunk_processor = fn
{:done, %{token_summary: token_summary}} ->
fire_token_usage_callback(model, token_summary)
final_delta = MessageDelta.new!(%{role: :assistant, status: :complete})
Callbacks.fire(model.callbacks, :on_llm_new_delta, [final_delta])
final_delta
content when is_binary(content) ->
case MessageDelta.new(%{content: content, role: :assistant, status: :incomplete}) do
{:ok, delta} ->
Callbacks.fire(model.callbacks, :on_llm_new_delta, [delta])
delta
{:error, %Ecto.Changeset{} = changeset} ->
reason = Utils.changeset_error_to_string(changeset)
Logger.error(
"Failed to process received model's MessageDelta data: #{inspect(reason)}"
)
raise LangChainError.exception(changeset)
end
end
result =
stream
|> Stream.map(&chunk_processor.(&1))
|> Enum.to_list()
# return a list of a list to mirror the way ChatGPT returns data
[result]
end
defp fire_token_usage_callback(model, %{input: input, output: output} = token_summary) do
Callbacks.fire(model.callbacks, :on_llm_token_usage, [
TokenUsage.new!(%{input: input, output: output, raw: token_summary})
])
end
defp fire_token_usage_callback(_model, _token_summary), do: :ok
@doc """
Generate a config map that can later restore the model's configuration.
"""
@impl ChatModel
@spec serialize_config(t()) :: %{String.t() => any()}
def serialize_config(%ChatBumblebee{} = model) do
Utils.to_serializable_map(
model,
[
:serving,
:template_format,
:stream,
:seed
],
@current_config_version
)
end
@doc """
Restores the model from the config.
"""
@impl ChatModel
def restore_from_map(%{"version" => 1} = data) do
ChatBumblebee.new(data)
end
end