Multimodality & Voice
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
This page was written against langchain 1.4.3, langchain-core 1.6.5, langgraph 1.2.12, langchain-openai 1.6.6 and
langchain-anthropic 1.7.4 on Python 3.12, with the openai SDK 3.20.0 for the speech examples, as verified on
2026-09-29. The running example is DocsAssistant, which answers questions over this site’s documentation using
Ollama’s llama3.1:8b chat model and the nomic-embed-text embedding model over the pgvector store built in
Integrating with LangChain. Ollama was not available
while the snippets were checked, and no hosted provider was called: every output shown here was produced by running
the code against the real packages with GenericFakeChatModel for the agent and an in-process HTTP transport that
stands in for the provider’s servers. A real model would answer differently, and a real speech service returns real
audio, but the request shapes, the block structure and the control flow are exactly what the libraries produce.
A text-only agent is a keyboard-shaped agent. Users have screenshots of an error, a PDF of the contract, a voice
note recorded on a phone, and sometimes they want the answer spoken back. LangChain’s answer is small: media travels
in the same messages list as text, as typed content blocks, and speech is handled by ordinary components placed
before and after the agent. What models can do with pixels and waveforms, and why, is covered in
Multimodal Models; this page is the plumbing. The full voice-agent
design space — turn detection, barge-in, telephony, latency budgets — belongs to the
Voice Agents sub-section (start with Voice Architectures); here it is limited to the pattern the official LangChain documentation builds.
Standard content blocks
Providers disagree on how to attach an image: an image_url part in one API, a source object in another.
LangChain 1.x defines provider-neutral blocks in content_blocks (introduced in
Chat Models & Messages) and translates them per provider:
type |
Carries | Data forms |
|---|---|---|
|
Photos, screenshots, diagrams |
|
|
Speech, recordings |
|
|
Clips |
|
|
PDFs and other documents |
|
|
Text documents ( |
Text, or |
A base64 block must carry its mime_type. Extra provider-specific keys go at the top of the block or under
extras; OpenAI, for example, wants a filename on a PDF. Not every model accepts every type, so check the
provider’s integration page for supported formats and size limits.
DocsAssistant gets a new job: a user attaches an architecture screenshot, a page exported as PDF and a voice note, and asks whether they agree. A small helper turns a local file into the right block:
import base64
import mimetypes
from pathlib import Path
from langchain.messages import HumanMessage
def file_block(path: str) -> dict:
"""Turn a local file into a standard LangChain content block."""
mime, _ = mimetypes.guess_type(path)
mime = {"audio/x-wav": "audio/wav"}.get(mime, mime) # the name the provider APIs expect
kind = {"image": "image", "audio": "audio", "video": "video"}.get((mime or "").split("/")[0], "file")
block = {
"type": kind,
"base64": base64.b64encode(Path(path).read_bytes()).decode("ascii"),
"mime_type": mime,
}
if kind == "file":
block["filename"] = Path(path).name # OpenAI requires a filename for PDFs
return block
msg = HumanMessage(content_blocks=[
{"type": "text", "text": "Is this diagram consistent with the attached page and the voice note?"},
file_block("architecture.png"),
file_block("vector-rag.pdf"),
file_block("question.wav"),
])
for b in msg.content_blocks:
print({k: (v if k != "base64" else f"<{len(v)} base64 chars>") for k, v in b.items()})
Output (executed, with a 64 by 32 pixel PNG, a one-page PDF and 0.1 s of silence as stand-ins):
{'type': 'text', 'text': 'Is this diagram consistent with the attached page and the voice note?'}
{'type': 'image', 'base64': '<224 base64 chars>', 'mime_type': 'image/png'}
{'type': 'file', 'base64': '<260 base64 chars>', 'mime_type': 'application/pdf', 'filename': 'vector-rag.pdf'}
{'type': 'audio', 'base64': '<4328 base64 chars>', 'mime_type': 'audio/wav'}
The 166-byte PNG became 224 base64 characters: base64 inflates media by about a third, and that inflated text lives
in the message. Prefer a url block (or a provider file_id from a prior upload) for anything large, and think twice before
letting a checkpointer (see Short-Term Memory & Checkpointers)
store a base64 photo in every saved state. The block reference and the url, base64 and
file_id forms are documented in LangChain — Messages.
Provider translation and capability checks
The standard block is what you write; the integration package rewrites it into the provider’s wire format when the
request is made. This run points ChatOpenAI at an in-process transport that records the request body, then sends
the same message to ChatAnthropic:
import json
import httpx
from langchain_anthropic import ChatAnthropic
from langchain_openai import ChatOpenAI
captured = {}
def fake_openai(request: httpx.Request) -> httpx.Response:
captured["body"] = json.loads(request.content) # keep what would have gone over the wire
return httpx.Response(200, json={
"id": "x", "object": "chat.completion", "created": 0, "model": "gpt-6-sol",
"choices": [{"index": 0, "finish_reason": "stop",
"message": {"role": "assistant", "content": "It matches."}}],
})
def shorten(x):
if isinstance(x, dict):
return {k: shorten(v) for k, v in x.items()}
return x[:30] + "..." if isinstance(x, str) and len(x) > 60 else x
openai_model = ChatOpenAI(model="gpt-6-sol", api_key="unused",
http_client=httpx.Client(transport=httpx.MockTransport(fake_openai)))
print(openai_model.invoke([msg]).text)
for part in captured["body"]["messages"][0]["content"]:
print(shorten(part))
claude = ChatAnthropic(model="claude-sonnet-5", api_key="unused")
try:
claude.invoke([msg])
except ValueError as e:
print("anthropic:", e)
Output (executed; the first line, the reply, is the canned response from the stand-in server, and the text part of the request is omitted here):
It matches.
{'type': 'image_url', 'image_url': {'url': 'data:image/png;base64,iVBORw0K...'}}
{'type': 'file', 'file': {'file_data': 'data:application/pdf;base64,JV...', 'filename': 'vector-rag.pdf'}}
{'type': 'input_audio', 'input_audio': {'data': 'UklGRqQMAABXQVZFZm10IBAAAAABAA...', 'format': 'wav'}}
anthropic: Block of type audio is not supported.
One neutral message became three OpenAI-specific shapes, and the filename requirement is why the helper adds
filename to PDFs. The Anthropic integration accepted the image and the PDF (a separate run of the same message
without the audio block got as far as opening a connection) and rejected the audio block before any network call.
Two consequences follow. A model swap can silently change which modalities work, so gate on capability rather than
trusting the block; and a rejected block is an exception in your code, not a model refusal you can prompt around.
The fake HTTP transport does not check whether gpt-6-sol accepts audio input; consult the provider page for the
model you actually deploy. Each provider’s integration page documents its own formats:
ChatOpenAI and
ChatAnthropic. Local models served through Ollama
usually handle images only when the specific model is multimodal, and none of the examples here needs one; see
Chat Models & Messages for ChatOllama.
Multimodal input to an agent
An agent from create_agent takes the same messages list, so attachments need no special handling:
Agents with create_agent built the loop, and the message with media
is just another HumanMessage in its state.
from langchain.agents import create_agent
from langchain.messages import AIMessage
from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
class FakeModel(GenericFakeChatModel):
def bind_tools(self, tools, **kwargs):
return self
agent = create_agent(
FakeModel(messages=iter([AIMessage("The diagram matches the page.")])),
tools=[],
system_prompt="You are DocsAssistant. Compare attachments with the documentation.",
)
result = agent.invoke({"messages": [msg]})
print([m.type for m in result["messages"]])
print([b["type"] for b in result["messages"][0].content_blocks])
print(result["messages"][-1].text)
Output (executed):
['human', 'ai']
['text', 'image', 'file', 'audio']
The diagram matches the page.
Swap FakeModel for a real model that accepts all four block types and the agent can call docs_search between
looking at the screenshot and answering. Everything in
Tools applies unchanged; only the input is richer. Two habits keep multimodal agents
cheap. Images and audio are billed in tokens and dominate the context window, so summarize or drop them from old
turns with the techniques in Context Engineering for Agents.
And treat text extracted from an image or PDF as untrusted input: an instruction embedded in a screenshot reaches
the model exactly like one typed by the user, which is the subject of
Guardrails & Security. See
LangChain — Agents for the agent API.
Media coming back
Models can return media too. In LangChain the reply is an AIMessage whose content_blocks include image (or
audio) blocks next to the text, with the bytes in base64. Reading them is the same loop for every provider that
returns them:
import base64
from pathlib import Path
from langchain.messages import AIMessage
png = Path("architecture.png").read_bytes()
reply = AIMessage(content_blocks=[
{"type": "text", "text": "Here is the diagram."},
{"type": "image", "base64": base64.b64encode(png).decode(), "mime_type": "image/png"},
])
for block in reply.content_blocks:
if block["type"] == "image":
Path("generated.png").write_bytes(base64.b64decode(block["base64"]))
print("saved", block["mime_type"], len(png), "bytes")
print(reply.text)
Output (executed, on a hand-built reply rather than a model call):
saved image/png 166 bytes
Here is the diagram.
How to ask a provider for an image in the first place is provider-specific; the OpenAI integration page documents image generation as a built-in tool the model may call. See ChatOpenAI for that and for the native-format alternatives to the standard blocks.
Transcription and text-to-speech
Most production voice systems do not send audio to the chat model at all. They use dedicated speech-to-text (STT)
and text-to-speech (TTS) services around a text model, which keeps the strongest reasoning model in charge and
makes each stage replaceable. LangChain does not wrap these services behind one interface; the official voice-agent
guide writes small adapters around the vendors' SDKs and WebSocket APIs (AssemblyAI for STT and Cartesia for TTS)
and says the same shape works for other providers. This page uses the openai SDK because it covers both
directions in one package:
import httpx
from openai import OpenAI
seen = []
def fake_openai(request: httpx.Request) -> httpx.Response:
seen.append((request.method, request.url.path, request.headers["content-type"].split(";")[0]))
if request.url.path.endswith("/transcriptions"):
return httpx.Response(200, json={"text": "How do I create an HNSW index?"})
return httpx.Response(200, content=b"RIFF....WAVEfmt fake-audio", headers={"content-type": "audio/wav"})
client = OpenAI(api_key="unused", http_client=httpx.Client(transport=httpx.MockTransport(fake_openai)))
# Real code: client = OpenAI() -- the key is read from the OPENAI_API_KEY environment variable.
def transcribe(path: str) -> str:
with open(path, "rb") as f:
return client.audio.transcriptions.create(model="gpt-4o-transcribe", file=f).text
def speak(text: str) -> bytes:
return client.audio.speech.create(
model="gpt-4o-mini-tts", voice="alloy", input=text, response_format="wav",
instructions="Calm, concise support agent.",
).read()
print(transcribe("question.wav"))
print(speak("Use CREATE INDEX ... USING hnsw."))
print(seen)
Output (executed against the stand-in transport, so the audio bytes are fake):
How do I create an HNSW index?
b'RIFF....WAVEfmt fake-audio'
[('POST', '/v1/audio/transcriptions', 'multipart/form-data'), ('POST', '/v1/audio/speech', 'application/json')]
The model names come from the SDK’s own list of transcription and speech models in version 3.20.0. The two endpoints
differ in shape: transcription uploads the audio as multipart/form-data and returns text; speech posts JSON and
returns audio bytes, and its instructions parameter steers delivery. Keys come from OPENAI_API_KEY in real code,
never from source. Other providers offer the same two operations under different names, and a self-hosted server that
speaks the same audio API can be reached by pointing the client at its base URL, which keeps the audio on your own
infrastructure. Look up the request formats in
OpenAI — Speech to text and
OpenAI — Text to speech.
A model that accepts an audio content block, as in the first section, is a different design: the model listens
directly and can use tone and pauses, at the price of a narrower choice of models and less visibility into what it
understood. Transcribing first gives you a text you can log, evaluate and guard.
The STT, agent, TTS sandwich
The official guide, Build a voice agent with LangChain, names two architectures. In the sandwich, speech-to-text feeds a text agent whose reply goes through text-to-speech; in speech-to-speech, one multimodal model consumes and produces audio natively. The sandwich is the one the guide builds, because it keeps full control of each part, gives access to the newest text models and tools, and has clear boundaries between components. Its costs are more services to run and the loss of tone and emotion when speech becomes text.
partial and final transcripts] STT -- final transcript --> A[create_agent
tools and checkpointer] A -- reply tokens --> S[Sentence buffer] S --> TTS[Text-to-speech
audio chunks] TTS --> P([Playback]) A <-. thread_id .-> M[(Checkpointer)]
The diagram above is the whole design. Each box is independent, so an STT vendor, a TTS voice or the chat model can change without touching the other two, and the agent in the middle is exactly the one built in Agents with create_agent, with the checkpointer from Short-Term Memory & Checkpointers keeping the conversation across spoken turns.
Batch turn with LCEL
The simplest sandwich handles one recorded utterance and returns one audio reply. Because each stage is a function,
the whole thing is an LCEL chain (see LCEL & Runnables), and naming the
stages with with_config(run_name=…) makes them show up as separate steps in a trace:
from langchain.agents import create_agent
from langchain.messages import AIMessage, HumanMessage
from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
from langchain_core.runnables import RunnableLambda
class FakeModel(GenericFakeChatModel):
def bind_tools(self, tools, **kwargs):
return self
agent = create_agent(FakeModel(messages=iter([AIMessage("Create it with CREATE INDEX USING hnsw.")])),
tools=[], system_prompt="Answer in one short spoken sentence.")
sandwich = (
RunnableLambda(transcribe).with_config(run_name="stt")
| RunnableLambda(lambda q: {"messages": [HumanMessage(q)]})
| agent
| RunnableLambda(lambda state: state["messages"][-1].text).with_config(run_name="reply_text")
| RunnableLambda(speak).with_config(run_name="tts")
)
audio = sandwich.invoke("question.wav")
print(type(audio).__name__, len(audio), "bytes")
Output (executed with the transcribe and speak functions from the previous section):
bytes 26 bytes
The 26 bytes are the fake audio from the stand-in transport. This version is easy to test and trace, but the user hears nothing until the whole reply exists, which for a voice assistant feels like a hung phone line. See LangChain — Build a voice agent with LangChain.
Streaming turn
The official guide streams at every stage so that speech starts before the agent has finished writing. The agent
stage uses astream_events(…, version="v3") and iterates stream.messages and each message’s text, which yields
tokens as they are generated. The same shape works here, with one addition: tokens are grouped into sentences before
they reach TTS, because synthesizing single words sounds choppy and per-request overhead would dominate.
import asyncio
import re
from collections.abc import AsyncIterator
from langchain.agents import create_agent
from langchain.messages import AIMessage, HumanMessage
from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
from langgraph.checkpoint.memory import InMemorySaver
VOICE_PROMPT = (
"You are DocsAssistant answering by voice. Reply in one or two short sentences of plain text: "
"no markdown, lists, code or emojis, because a speech engine reads your answer aloud."
)
class FakeModel(GenericFakeChatModel):
def bind_tools(self, tools, **kwargs):
return self
model = FakeModel(messages=iter([
AIMessage("Create it with CREATE INDEX USING hnsw. Raise ef_search when recall drops."),
AIMessage("Yes, IVFFlat is the alternative."),
]))
agent = create_agent(model, tools=[], system_prompt=VOICE_PROMPT, checkpointer=InMemorySaver())
SENTENCE_END = re.compile(r"(?<=[.!?])\s+")
async def sentences(tokens: AsyncIterator[str]) -> AsyncIterator[str]:
"""Group streamed tokens into sentences so TTS gets natural units, not single words."""
buffer = ""
async for token in tokens:
buffer += token
*done, buffer = SENTENCE_END.split(buffer)
for sentence in done:
yield sentence
if buffer.strip():
yield buffer.strip()
async def voice_turn(audio_path: str, thread_id: str) -> list[bytes]:
question = await asyncio.to_thread(transcribe, audio_path) # STT
print("heard:", question)
stream = await agent.astream_events( # agent
{"messages": [HumanMessage(question)]},
{"configurable": {"thread_id": thread_id}},
version="v3",
)
async def tokens() -> AsyncIterator[str]:
async for message in stream.messages:
async for token in message.text:
yield token
clips = []
async for sentence in sentences(tokens()): # TTS, one sentence at a time
print("speaking:", sentence)
clips.append(await asyncio.to_thread(speak, sentence))
return clips
async def main() -> None:
for _ in range(2): # two turns on one thread: the checkpointer carries the conversation
clips = await voice_turn("question.wav", "voice-thread-1")
print(len(clips), "audio clips")
state = await agent.aget_state({"configurable": {"thread_id": "voice-thread-1"}})
print(len(state.values["messages"]), "messages in the thread")
asyncio.run(main())
Output (executed; the first reply has two sentences, so two audio clips, and the second turn reuses the thread):
heard: How do I create an HNSW index?
speaking: Create it with CREATE INDEX USING hnsw.
speaking: Raise ef_search when recall drops.
2 audio clips
heard: How do I create an HNSW index?
speaking: Yes, IVFFlat is the alternative.
1 audio clips
4 messages in the thread
The blocking SDK calls run in asyncio.to_thread so they do not stall the event loop; a vendor with a streaming
WebSocket API, as in the official guide, would replace them with a producer that sends text as it arrives and a
consumer that yields audio chunks. LangChain marks the v3 streaming protocol as experimental, so expect
LangChainBetaWarning on the two calls above and check the streaming page before upgrading; the more stable stream
modes are covered in Streaming. The demo repository referenced by the guide,
voice-sandwich-demo, shows the full pipeline with a browser
client and WebSockets.
What the pattern needs beyond the code
-
Write for the ear. The system prompt above forbids markdown, lists and code, since the speech engine would read the symbols aloud. Keep answers to a sentence or two, and make DocsAssistant offer to send the long version to the user’s screen instead of reading a table.
-
Latency is the product. Time to first audio is STT finalization plus time to the first sentence plus TTS start. Stream every stage, stream the model, keep the tool loop short (see Tool Design), and use the smaller models for routine turns.
-
Interruptions. Real callers talk over the agent. That needs turn detection, cancelling the TTS clips still queued and discarding the rest of the reply; it is a property of the audio transport rather than of LangChain, and is where a dedicated voice framework earns its keep.
-
Approvals. A destructive tool, such as DocsAssistant opening a GitHub issue, still needs a human decision. A spoken "yes" is weak evidence; confirm on a screen or read the action back and require an explicit phrase. See Human-in-the-Loop & Time Travel.
-
Privacy. Voice is personal data, and transcripts and recordings sent to a hosted STT or TTS service leave your infrastructure. Choose retention settings deliberately, and see Agent Guardrails & Safety and Guardrails & Security.
-
Tracing. LangSmith recommends recording a whole voice conversation as a single trace with a combined audio file on the root run, and marking it as audio, so barge-ins and gaps between speakers stay visible; see LangSmith — Voice tracing fundamentals and Observability with LangSmith.
Choosing an approach
| Need | Use | Trade-off |
|---|---|---|
Answer questions about an image or PDF |
|
Media tokens are expensive; check per-model limits |
The user speaks and reads text back |
STT before the agent |
Simple, testable; loses tone |
The user speaks and hears the answer |
The sandwich: STT, agent, TTS with sentence streaming |
Several services and a latency budget to manage |
Audio in, audio out with the fewest parts |
A speech-to-speech model |
Fewer model choices, less control and visibility |
Phone calls, barge-in, telephony |
A voice framework; see LiveKit Agents or Pipecat |
Outside LangChain proper |
Multimodal attachments and voice both widen the attack surface and the bill, so build the text agent first, evaluate it as in Agent Evaluation, and only then put media around it. For the models that can run on your own machine, see Running LLMs Locally.