跳至内容

实用指南:开源智能体框架与 ElevenAgents

发布时间
最近更新

收听收听本文

在上一篇文章 将外部智能体集成到 ElevenLabs 语音编排 中,我们介绍了团队如何通过 Custom LLM 将现有的文本智能体编排接入 ElevenLabs。本指南将在此基础上说明,如何调整主流开源智能体框架,并将其部署在 Custom LLM 接口之后。这样可在成熟的智能体系统之上叠加语音能力,同时不影响状态管理、工具编排或应用特定控制。无论使用哪种框架,都遵循相同的 3 步流程:创建生成请求、提取最终文本响应,并将其重新格式化为兼容 OpenAI 的服务器发送事件(SSE)格式。ElevenLabs 同时支持 Chat Completions Responses 格式。本指南涵盖 4 个广泛采用的框架,但这些模式同样适用于任何能生成兼容 OpenAI 流式输出的运行时。

A proxy layer translates between ElevenLabs voice orchestration and an agent framework, converting OpenAI-style messages into framework inputs and streaming SSE chunks back as agent voice output.

通用设置

本节示例使用 Python 和 FastAPI,但任何能处理 HTTP POST 请求和流式 SSE 响应的技术栈都适用。当 ElevenLabs 的语音编排检测到对话轮次可能结束时,会向配置的 Custom LLM 端点发起生成请求。本节将介绍这一转换层的核心组件,即让语音编排与智能体框架使用同一种语言的桥接层或代理层。

客户选择不同框架,通常是因为熟悉该框架,或它能满足特定需求。例如,LlamaIndex 最初旨在简化检索增强生成(RAG)的搭建,而 CrewAI 则面向智能体时代的明确任务自动化。不同设计目标会产生不同响应结构,因此需要针对性处理。让 LLM 在生成时即时流式传输内容,而非等待完整轮次结束,至关重要:这让文本转语音(TTS)模型能更早开始生成语音,从而降低感知延迟。本文主要聚焦 4 个热门框架:LangGraph、Google ADK、CrewAI 和 LlamaIndex。

共享代码说明

每个框架都必须以兼容 OpenAI 的 SSE 数据块流式传输响应。我们先介绍一个供各示例共用的小型辅助函数,用于构建这些数据块。

def sse_chunk(response_id: str, delta: dict, finish_reason=None) -> str:
    payload = {
        "id": response_id,
        "object": "chat.completion.chunk",
        "choices": [{"index": 0, "delta": delta, "finish_reason": finish_reason}],
    }
    return f"data: {json.dumps(payload)}\n\n"

基础准备就绪后,先来看 LangGraph。 

LangGraph

LangGraph 将 智能体 建模为图:节点代表各个步骤,边定义节点之间的控制流。最简设置很直接:初始化聊天模型、定义智能体工具并创建智能体图运行时。

from langchain.agents import create_agent
from langchain_openai import ChatOpenAI
from langchain_core.tools import tool
llm = ChatOpenAI(
    model=model_id,
    api_key=os.getenv("OPENAI_API_KEY"),
)
agent = create_agent(
    llm,
	tools=tool_list,
	system_prompt=system_prompt,
)

每次生成请求时,LangGraph Agent 都会接收完整对话历史,从而在内部维护所需状态。LangGraph 支持通过 Checkpoints 实现服务端持久化,但为保持实现简洁,本文不展开介绍。

处理完状态管理后,下一个 LangGraph 特有的决策点是流式模式。LangGraph 提供两种选项,分别适合不同场景:

  • stream_mode="values" 提供图状态快照。实现更简单,但每次响应都会包含更完整的消息状态,会增加实时对话流程的延迟。
  • stream_mode="messages" 流式输出模型逐步生成的消息块。它通常更适合实时语音交互,因为能缩短 ElevenLabs 编排层生成首段音频的时间。

更具体地说,智能体循环的 messages 实现包含 工具调用 更新等不应朗读的中间步骤。代理会过滤这些内容,仅将面向用户的响应文本传给 TTS 层。下面是一个启用工具的对话轮次示例。

[1] 模型决定调用工具(tool_calls=["get_price"])
[2] 工具执行并返回数据(result="$24.99") 
[3] 模型使用结果生成响应(content="价格为 $24.99") 

显然,SSE 流中只应转发第 3 步的数据块。实际操作中,流式循环中的两项检查可完成过滤:一项仅保留 langgraph_node == "model" 事件,另一项跳过空内容。两者结合,确保只有面向用户的助手文本以 SSE 形式转发至 ElevenLabs。结合这些概念,下面给出一个轻量级请求代理实现。

@app.post("/chat/completions")
async def chat_completions(req: ChatCompletionRequest):
    input = {"messages": req.messages}
    async def stream():
        response_id = f"chatcmpl-{uuid.uuid4().hex[:12]}"
        sent_role = False
        async for message_chunk, metadata in agent.astream(input, stream_mode="messages"):
            # Only forward model text chunks; skip tool updates and non-text events.
            if metadata.get("langgraph_node") != "model":
                continue
            content = getattr(message_chunk, "content", None)
            if not content:
                continue
            if not sent_role:
                yield sse_chunk(response_id, {"role": "assistant"})
                sent_role = True
            # Send incremental token-like chunks to ElevenLabs in OpenAI format.
            yield sse_chunk(response_id, {"content": content})
         # Signal natural completion before using the finish_reason: "stop" [DONE]
        yield sse_chunk(response_id, {}, finish_reason="stop")
        yield "data: [DONE]\n\n"
    return StreamingResponse(stream(), media_type="text/event-stream")

这样可确保只有面向用户的模型数据块会被转发至 ElevenLabs。由于 LangGraph 会通过状态流公开内部工具执行过程,过滤由代理明确控制。 

接下来,我们深入了解 Google Agent Development Kit(ADK)的使用要点。

Google ADK

Google 的 ADK 通过几个核心原语抽象了运行时循环:Agent、Runner 和 SessionService。ADK 的 Runner 位于 HTTP 层和智能体定义之间,负责消息路由、工具编排、会话生命周期和事件流式传输。 

from google.adk.agents import Agent
from google.adk.runners import Runner
from google.adk.agents.run_config import RunConfig, StreamingMode
from google.adk.sessions import InMemorySessionService
from google.genai import types as genai_types
agent = Agent(
    name=name,
    model=model,
    instruction=instruction,
    tools=[tool_list],
)
session_service = InMemorySessionService()
	runner = Runner(
	agent=agent,
	app_name=app_name,
	session_service=session_service
)

初始化智能体、会话后端和 runner 后,代理会为每个传入请求查找或创建 ADK 会话。在 ADK 中,session_id 控制记忆持久化:跨轮次复用同一 session_id,会自动保留历史记录、工具调用和先前响应。由于对话标识由 ElevenLabs 上游维护,代理会明确处理这一映射。为生成请求传入正确标识符后,SDK 即可在内部处理先前上下文。我们在发起对话时,通过传入请求正文的 额外参数 传递该任意标识符。  

准备好消息和会话后,即可调用 runner。执行期间,工具调用及其结果仍会作为 ADK 内部事件出现,但会被视为中间编排步骤,而非面向用户的输出。相比工具调用以用户可见文本出现的框架,这无需手动过滤。 

以下处理程序是简化实现,内联包含会话查找以及获取或创建逻辑。

@app.post("/chat/completions")
async def chat_completions(req: ChatCompletionRequest, request: Request):
    # In production, prefer a stable identifier from your upstream system.
    session_id = req.elevenlabs_extra_body.arbitrary_identifier
    session = await session_service.get_session(
        app_name="elevenlabs", user_id="user", session_id=session_id
    )
    if not session:
        session = await session_service.create_session(
            app_name="elevenlabs", user_id="user", session_id=session_id
        )
    user_text = next((m["content"] for m in reversed(req.messages) if m["role"] == "user"), "")
    content = genai_types.Content(role="user", parts=[genai_types.Part(text=user_text)])
   async def stream():
        response_id = f"chatcmpl-{uuid.uuid4().hex[:12]}"
        sent_role = False
        async for event in runner.run_async(
            user_id="user",
            session_id=session.id,
            new_message=content,
            run_config=RunConfig(streaming_mode=StreamingMode.SSE),
        ):
            if not event.content or not event.content.parts:
                continue
            # In SSE mode, ADK emits partial (incremental) and final (complete) events.
            # Forwarding only partial events avoids duplicating the full text.
            # Note: SSE streaming is experimental in ADK. For production, reconcile
            # both event types in case the model backend doesn't emit partials.
            if not getattr(event, "partial", False):
                continue
            text = "".join((getattr(p, "text", "") or "") for p in event.content.parts)
            if not text:
                continue
            if not sent_role:
                yield sse_chunk(response_id, {"role": "assistant"})
                sent_role = True
            yield sse_chunk(response_id, {"content": text})
        yield sse_chunk(response_id, {}, finish_reason="stop")
        yield "data: [DONE]\n\n"
    return StreamingResponse(stream(), media_type="text/event-stream")

接下来看看 CrewAI,它的设计更以任务为中心。

CrewAI

CrewAI 的设计目标是围绕结构化任务(研究、撰写、总结)编排多智能体 workflow,而不是处理开放式对话循环。智能体由角色、目标和背景故事定义。执行则以 Task 对象为中心,每个对象都有明确描述和预期输出。 

from crewai import Agent, Task, Crew, Process, LLM
from crewai.tools import tool
from crewai.types.streaming import StreamChunkType
llm = LLM(
    model=model_id,
    api_key=os.getenv("OPENAI_API_KEY")
)
store_agent = Agent(
    role=role,
    goal=goal,
    backstory=backstory,
    tools=tools,
    llm=llm,
    verbose=False,
)

不同于 LangGraph 和 ADK 使用的智能体循环模型,CrewAI 通常会为每个请求构建 Task 和 Crew,以定义该对话轮次的工作单元。我们通过占位符将先前轮次注入下一个任务,从而保留对话上下文。每次请求都会使用持续累积的对话历史填充 {crew_chat_messages} 变量,并在执行时插入任务描述。为生成干净、可直接朗读的文本,我们还会明确过滤中间追踪模式(Thought、Action、Action Input、Observation),只输出最终答案文本。 

以下处理程序整合了按请求构建任务、插入历史记录、Crew 级流式传输、追踪过滤和输出格式化。 

@app.post("/chat/completions")
async def chat_completions(req: ChatCompletionRequest):
    # Task and Crew are assembled per request (not at startup).	
    task = Task(
        description=(
            "Conversation history:\n{crew_chat_messages}\n\n"
            "Respond to the user's latest message."
        ),
        expected_output=expected_output,
        agent=store_agent,
    )
    # stream=True returns CrewStreamingOutput instead of a single CrewOutput.
    crew = Crew(
        agents=[store_agent],
        tasks=[task],
        process=Process.sequential,
        verbose=False,
        stream=True,
    )
    async def stream():
        response_id = f"chatcmpl-{uuid.uuid4().hex[:12]}"
        sent_role = False
        final_marker = "final answer:"
        marker_buffer = ""
        marker_found = False
        emitted_any_content = False
        streaming = await crew.kickoff_async(
            inputs={"crew_chat_messages": json.dumps(req.messages)}
        )
       async for chunk in streaming:
            # Skip non-text events (e.g. tool calls).
            if chunk.chunk_type != StreamChunkType.TEXT or not chunk.content:
                continue
            # Only forward text after the "Final Answer:" marker
            if not marker_found:
                marker_buffer += chunk.content
                idx = marker_buffer.lower().find("final answer:")
                if idx == -1:
                    continue
                marker_found = True
                content = marker_buffer[idx + 13:].lstrip()
                marker_buffer = ""
            else:
                content = chunk.content
            # Clean up any trailing markdown artifacts from CrewAI output.
            content = content.rstrip("`").rstrip()
            if not content:
                continue
            if not sent_role:
                yield sse_chunk(response_id, {"role": "assistant"})
                sent_role = True
            yield sse_chunk(response_id, {"content": content})
        # Fallback to handle short responses without the "Final Answer:" marker
        if not sent_role:
            raw = getattr(streaming, "result", None)
            fallback = (raw.raw if raw else marker_buffer).strip().rstrip("`").rstrip()
            if fallback:
                yield sse_chunk(response_id, {"role": "assistant"})
                yield sse_chunk(response_id, {"content": fallback})
        yield sse_chunk(response_id, {}, finish_reason="stop")
        yield "data: [DONE]\n\n"

接下来看看 LlamaIndex。它采用不同路径,聚焦原生事件驱动的流式模型。

LlamaIndex

与本文介绍的其他框架不同,LlamaIndex 专为将 LLM 连接到外部数据源(文档存储、索引、检索管道)而设计。其智能体层 FunctionAgent 建立在这一基础之上,用于检索结构化上下文并进行推理,而非开放式对话或任务执行。

from llama_index.llms.openai import OpenAI
from llama_index.core.agent.workflow import FunctionAgent, AgentStream
from llama_index.core.base.llms.types import ChatMessage, MessageRole
llm = OpenAI(
    model=model,
    api_key=os.getenv("OPENAI_API_KEY")
)
agent = FunctionAgent(
    tools=[list_inventory, get_item_price],
    llm=llm,
    system_prompt=system_prompt,
)

为保持对话连续性,代理会将传入消息转换为 LlamaIndex 聊天消息,再将其拆分为最新用户轮次(user_msg)和先前轮次(chat_history)。每个 AgentStream 事件的 event.delta 字段都包含下一个文本片段,可直接映射为 OpenAI 风格的 delta.content 数据块。非空 delta 可原样转发,因此这是本指南中最直接的流式桥接方式。流中既包含编排事件(工具调用、结果),也包含语音事件(助手文本增量)。为保持语音输出干净,代理仅保留 AgentStream 事件并跳过空 delta。

[1] AgentStream(delta='')       ← 已忽略
[2] ToolCall                     ← 已忽略
[3] ToolCallResult               ← 已忽略
[4] AgentStream(delta='它')     ← 已转发 ✓
[5] AgentStream(delta=' 的价格是')← 已转发 ✓
[6] AgentStream(delta=' $49.99')← 已转发 ✓

这种分离方式可避免将中间工具机制读出来,同时保留低延迟的增量语音。下面可直接使用的处理程序整合了这些步骤。

@app.post("/chat/completions")
async def chat_completions(req: ChatCompletionRequest):
    # This assumes the last message is always a user turn with string content.
    # For production, add defensive role/content handling for non-text payloads.
    chat_history = [
        ChatMessage(role=MessageRole(m["role"]), content=m.get("content") or "")
        for m in req.messages
    ]
    user_text = chat_history.pop().content
    async def stream():
        response_id = f"chatcmpl-{uuid.uuid4().hex[:12]}"
        handler = agent.run(user_msg=user_text, chat_history=chat_history)
        async for event in handler.stream_events():
            if not isinstance(event, AgentStream):
                continue
            if not event.delta:
                continue
            yield sse_chunk(response_id, {"content": event.delta})
        yield sse_chunk(response_id, {}, finish_reason="stop")
        yield "data: [DONE]\n\n"
    return StreamingResponse(stream(), media_type="text/event-stream")

与内置编排层更重的框架相比,LlamaIndex 对端到端对话运行时模式的约束较少。在生产部署中,通常需要自行实现会话处理、响应防护、工具编排和追踪。

总结

本指南中的每个框架都通过同一约定接入 ElevenLabs:接受 OpenAI 风格的 Completions 或 Responses 请求,并流式返回 SSE 数据块。这样,团队只需对现有智能体实现做少量改动,就能在其上叠加语音编排,保留已构建的能力,同时解锁实时 对话式 AI。这种模块化是 ElevenAgents 平台的核心理念。无论是扩展现有智能体,还是从一开始构建原生语音智能体,ElevenAgent 的语音编排都能适配实际需求。

如果你已在使用开源框架运行智能体,并希望启用语音功能,不妨试试这种方法,并告诉我们你的体验。

相关内容

用高质量 AI 音频创作