Skip to main content
Unlisted page
This page is unlisted. Search engines will not index it, and only users having a direct link can access it.

2 posts tagged with "vector-database"

View All Tags

Agentic Backends: What I Wish Someone Had Told Me About API Design for LLMs

Agentic Backends: What I Wish Someone Had Told Me About API Design for LLMs
10 min read

Modern LLMs APIs have this powerful feature called "tool calling" or "function calling". Essentially, they can call API endpoints or functions to perform actions in the real world. Send an email, update a database, fetch weather data, etc.

Naturally, when developers discover this, their first reaction is: "Perfect! We already have an API. We'll just expose our existing endpoints as tools and let the LLM use them." This is seen as a quick and elegant way toward a conversational UI for your APIs.

Except it doesn't really work.

The Core Issue​

While LLMs are essentially API consumers, they're fundamentally different from normal ones, which are... human developers. When an informed developer uses an API, they bring a lot of implicit knowledge to the table:

  • Familiarity with your system's concepts and entities
  • Understanding of the business logic and purpose
  • Knowledge of the API's modularity and flow

But with LLMs, even if you feed them with some context, they're (comparatively) flying blind.

The problem is further amplified by the fact that LLMs are trained on billions of pages of human text like stories, conversations, and articles. So they're incredibly good at understanding natural language patterns. But APIs live in a different semantic universe. They contain numerous idiosyncracies that will trip up even the most sophisticated models.

Even more importantly, most APIs have zero tolerance for mistakes of even the smallest kind. It's not enough to be "semantically similar" when filling out a parameter. You have to follow a strict contract with absolute consistency.

Fred Brooks put it best in his classic book, The Mythical Man-Month: “If one character, one pause, of the incantation is not strictly in proper form, the magic doesn’t work [...] Adjusting to the requirement for perfection is, I think, the most difficult part of learning to program.”

Modularity is a Double-Edged Sword​

Perhaps most critically, traditional APIs are modular by design. Want to send an email to a user? First, fetch the user's details. Then validate their email preferences. Finally, call the send endpoint. Each step has its own error states and success conditions.

For a human developer, this modularity is a crucial feature. For an LLM, it's literally begging for trouble.

We've consistently watched LLMs:

  • Call step 3 without doing steps 1 and 2
  • Repeat step 1 endlessly because they forgot they already did it
  • Invent parameters for step 2 based on a creative interpretation of the function name
  • Pass arguments that break the type system or violate the API contract
  • Give up entirely and hallucinate a success response

Time for a Different Approach​

Many are working on making LLMs smarter about API interpretation and coding. This is a good step, but we need to do more. It's not enough to improve function-calling quality by another few percentage points.

We need to rethink how we design for LLMs. We need to understand that LLMs are a different type of API consumer with different needs, and design our APIs accordingly.

Essentially, what I'm suggesting is applying the pattern of BFF or Backend for Frontend, but to LLM (or agentic) frontends.

In other words, instead of creating a single API that categorically serves all consumers, we create a specialized API that is tailored to the unique needs of agentic frontends.

With that in mind, let's look at some of the most common failure modes and how to avoid them when designing APIs for LLMs.

github Get in touch with us

1. Tools Need to Be Standalone​

Traditional API design embraces the principle of composability: small, reusable functions that work together. Need to update a user? First fetch their ID, then call the update endpoint.

But LLMs work best when they can match a clear user intent to a single action.

Consider these two API designs:

Traditional (Bad for Agents):

def get_user_id(email: str) -> int: ...
def update_user_preferences(user_id: int, preferences: dict) -> bool: ...

Agent-Friendly (Good):

def update_user_preferences_by_email(email: str, preferences: dict) -> bool: ...

Agents struggle with multi-step workflows. Each tool call is a decision point where things can go wrong. The agent might:

  • Forget to call get_user_id()
  • Forget to store the intermediate result
  • Misunderstand which ID to use if multiple are in context
  • Hallucinate a user ID based on partial information
  • Lose track of the overall goal between calls

Key Principle: Between "call the same function but parameterize it differently" and "create a standalone function for this particular context", try to heavily lean toward the latter. Think of a tool as a unique UI button instance.

Avoiding Ambiguity​

A key observation here is that LLM APIs will naturally contain a lot of redundancy. This is not a bug, it's a feature. The more you can make each tool call self-contained, the less cognitive load you place on the agent, and the more robust your agentic API will be. See what I mean when I say we need to think differently about agentic APIs?

Additionally, tool functions will sometimes differ from each other in only minor ways. For example, schedule_appointment() and reschedule_appointment(). In a human context, these are clear. But for an agent, they can be confusing. This means that you should try to control, to the best of your ability, what tools you even allow your LLM agent to evaluate at any given time.

Incidentally, this is exactly why, in Parlant, tools must be associated with guidelines and can only run when the conditions for their associated guidelines are active.

2. Tools Should Be Idempotent​

Like I said before, LLMs will often call the same tool multiple times during a single interaction, either because they forgot they already did it or because they mistakenly think they need to do it again.

For example, they'll mistakenly call make_transaction() or send_email() multiple times, causing a mess. So if a customer comes in and says, "I want to send $50 to Bill", the agent then calls make_transaction(amount=50, recipient="Bill"). Then the customer says, "Thanks, so it's done, right?" and the agent calls it again, thinking it needs to confirm the transaction.

And there are even more trivial examples like just calling a tool again because the context still kind of "makes sense."

This is why you should design your tools to be idempotent. In other words, calling them multiple times with equivalent parameters (equivalent being an important word we'll circle back to) should yield the same result without side effects.

In Parlant, one of the tricks you can use to achieve this is to use the session ID as an idempotence key.

import parlant.sdk as p

@p.tool
def make_transaction(
context: p.ToolContext,
amount: float,
recipient: str,
) -> p.ToolResult:
global TRANSACTION_IDEMPOTENCE_TABLE

if context.session_id in TRANSACTION_IDEMPOTENCE_TABLE:
return p.ToolResult("Transaction already made in this session")

# Proceed with the transaction logic

The approach above is more simplistic and aggressive, essentially allowing only a single transaction per session. Of course, there are more nuanced ways to approach it, but we'll leave it as an exercise for the reader.

github Get in touch with us

3. Names Matter More Than You Think​

Quick: what's the difference between these functions?

  • send_message()
  • send_notification()
  • send_email()
  • send_alert()

To human developers reading docs it may be crystal clear, because they understand the API's terminology, and that each entity is a fundamentally different creature.

To an LLM it's a minefield. You won't believe how many false positives it'll have here.

Bad Design:

def send_notification(user_id: str, title: str, message: str): ...
def send_message(recipient: str, content: str): ...

Good Design:

def send_email_to_user(email_address: str, subject: str, body: str):...
def send_phone_sms_to_user(phone_number: str, text_content: str): ...

Be tediously explicit. Use names that:

  • Include the key differentiator in the function name itself
  • Clearly indicate what type of data is expected
  • Avoid synonyms that might confuse
  • Attract contextual attention with literal clues

4. Parameters Are Queries in Disguise​

With regular APIs, you can often assume the developer abided by the contract and passed the right data. For example, they won't pass an email for user_id if they can see that the API has a UserID entity that's returned by other endpoints. If they do, you just return an error. And once they see that their code is broken, they fix it. Then they ship it to production, and everything works.

But LLMs don't have that luxury, and they keep trying to figure out your API, from scratch, every single time. They won't code and debug it until it works, and only then ship it.

So if you don't want your agent to consistently fail at tool calls, you need to provide tool functions that are as tolerant as possible toward their arguments.

An effective design pattern here is to treat every parameter in your tool as essentially a query for that parameter's value. Sometimes the translation is direct, and sometimes it requires some inference.

Consider this function:

def schedule_meeting(participant_1, participant_2):

If each participant must follow a strict schema, such as email or valid user ID, then, for each parameter, the agent must:

  1. Determine if it has this information
  2. Extract it from context if available
  3. Decide on a reasonable default if not
  4. Format it correctly

That's 8 potential failure modes right there.

Instead, you can think of each parameter as a query. Something like this:

import parlant.sdk as p

@p.tool
def schedule_meeting(
context: p.ToolContext,
participant_1: str | None = None,
participant_2: str | None = None,
) -> p.ToolResult:
participants = [participant_1, participant_2]

if None in participants:
return p.ToolResult("Both participants must be provided")

actual_users = []

for p in participants:
user = None

# infer_type classifies with regex or perhaps even with NLP models
match infer_type(participant_1):
case "email":
user = find_user_by_email(p):
case "user_id":
user = find_user_by_user_id(p)
case "full_name":
user = find_user_by_full_name(p)
case _: pass

if user is None:
return p.ToolResult(f"Could not find user for {p}")

# Proceed with scheduling the meeting using actual_users

This will produce much more consistency in production.

Agent-Friendly APIs​

These agent-friendly APIs are more verbose. They have more endpoints. They're more opinionated.

But we've learned that the cost of an agent misusing an API is far higher than the cost of a slightly larger API surface. One bad function call can derail an entire agent interaction, frustrating users and destroying trust.

Practical Checklist for Agentic API Design​

Before releasing an API for agent consumption, ask yourself:

  1. Can each tool facilitate a user's intent in a single, standalone call?
  2. Would a non-technical person understand non-ambiguously what each tool does from its name alone?
  3. Is each parameter name specific enough that misinterpretation is highly improbable?
  4. Are your tools built to be idempotent, so they can be called multiple times without risky side effects?
  5. Are you treating parameters as queries, allowing for flexible input without strict adherence to narrow contracts?

Conclusion​

As AI agents become more prevalent, we need to evolve our API design philosophy. The principles that served us well for human-consumed APIs—flexibility, composability, abstraction—can actually become liabilities in the agentic world.

The good news is that LLMs are incredibly powerful and actually quite consistent when we design for their characteristics.

Have you encountered other surprising challenges in agentic API design? I'd love to hear about them.

github Get in touch with us

Increasing the Accuracy of Embedding-Based Retrieval

Increasing the Accuracy of Embedding-Based Retrieval
10 min read

We've run into some interesting challenges with utterance matching in certain use cases of Parlant's heavier enterprise users.

For a bit of background, utterance matching is a unique Parlant feature designed for regulated enterprise customers who deploy customer-facing AI agents at an extremely large scale (hundreds of thousands to millions of monthly conversations). In those cases, even a hallucination rate of 0.01% is unacceptable (since, with hallucinations, it's not just a matter of their frequency—the bigger problem is their magnitude, which is often completely unbounded).

Utterance matching works by allowing the LLM to generate a fluid draft message, and then matching it to the best candidate out of a set of pre-defined utterance templates. This pre-defined set is created offline, normally by a conversation designer or business expert, and overrides the LLM's draft message to ensure that the agent's responses are safe and consistent with the company's policies and tone of voice.

The bigger the set of templates, the more likely it is that the LLM will generate a draft message that matches one of them, and the more likely it is that the agent's response will be appropriate for the conversation.

As with most things in software (and perhaps life), you start out thinking the problem is simple, but as you dig deeper, you realize that a robust solution is more complex than you initially thought.

In our case, the problem manifests once you have a large number of utterances, and you need to ensure that your ideal utterance is returned within the K most relevant results to consider for a given draft message. We do need to limit it to some small constant K, because otherwise the LLM will have too many options to choose from, and it will be less likely to pick the right one.

Now, it's important to note that, when looking at your utterance store, you usually know which one is the best fit for a given draft message—either because it's obvious, or because a conversation designer or business expert has told you that directly!

The naive approach is to use a vector database to index all of the utterance templates according to their content, and then find the closest template to the query. But it turns out this doesn't work well, and, just as importantly, it's unpredictable and inconsistent. More concretely, there are two big problems we've found with this approach:

  1. Many templates include fields in them. For example, I'm sorry to hear about {{generative.customer_issue}}. Please consider these solutions: {% for sm in suggested_mitigations %}\n- {{sm}}{% endfor %}. This field syntax causes cosine similarity to be smaller and often unexpected compared to the fluid (and fully rendered) draft message.
  2. Sometimes, the desired template is not the closest one to the draft message. It's particularly relevant when you just don't agree with how the LLM chose to phrase the draft message, and you want to reply with something more appropriate. For example, the draft message is, Certainly, I can help you book a flight to Paris. Please provide me with the dates and times you prefer. and the desired template is the simpler Sure, when are you going?, which may easily be further away in the vector space than other templates.

This means that the most fitting templates might appear outside of the top K results returned by the vector database. And the larger your set of templates is, the more likely this is to happen.

So, what can we do about it?​

The first thing we had to understand was that matching the right utterance depends on a human label. In other words, having a person say, "This template is the best for for this draft message."

But how do you get an arbitrarily-chosen best-fit template to return in the top K results using an off-the-shelf embedding model that you don't want to fine-tune yourself?

This is where we came up with a new concept in Parlant, which we call queries. Queries are strings that are attached to retrievable elements in Parlant—such as utterance templates. With queries, each utterance template has N>=1 embedding vectors associated with it: one for the content of the template itself, and one for each query that is attached to it.

When we retrieve utterance templates, we retrieve the top K templates based on the cosine similarity between the draft message's embedding and the embeddings of all of its queries, as well as the embedding of the template itself.

github Get in touch with us

How this works​

  1. A conversation designer notes, "This template is the best fit for this draft message."
  2. They then create a query that contains the draft message, and attach it to the utterance template. This is essentially the label that says "This is a great fit for this draft message."
  3. When the LLM generates a draft message, we retrieve the top K embeddings, containing both template as well as query embeddings.
  4. Since the added query is specifically designed to align with the draft message, it greatly increases the chances of retrieving it within the top K results.

But here's the tricky part: we don't simply want to retrieve the top K closest embeddings to a draft message; rather, we want to retrieve the top N templates.

For example, say that each template has a total of 2 embeddings associated with it: one for the template itself, and another one for an associated query. If we then want to retrieve the top 2 templates (note: not vectors, but the templates themselves), we can't just retrieve the top 2 embeddings, because we could easily end up with two embeddings that belong to the same template, which is not what we want.

And it's an even bigger problem if we have more than 2 embeddings per template (on average). In practice, we would have hundreds if not thousands of templates, and potentially dozens of queries per template.

The naive solution​

An easy approach would be to calculate the cosine similarity between our draft message's embedding and all of our stored embeddings, sort them by similarity, and then extract the top K templates from them.

However, this is not efficient, and doesn't scale well. If we have a large number of templates, this approach will take a long time to compute, and it will be very resource-intensive.

Toward an efficient solution​

This is where we came up with a new and simple approach that is both efficient and effective.

The idea is to utilize the vector database's KNN search capabilities to retrieve the top K embeddings, which is much more efficient than retrieving all of the embeddings, computing their similarity, and sorting them manually.

The problem with that approach is that, with vector databases, you need to tell them, "Get the top K closest vectors to this input vector." So what do we choose for K when we actually want the N templates (where N<=K)?

Concrete example​

Suppose we want to retrieve the top 2 templates, and that each template has between 1 to 10 embedding vectors associated with it.

As I said before, if we want to retrieve the top 2 templates (N=2), setting K=2 will not work, as we may get just one template whose entire 10 vectors are at the top of the similarity ranking. This won't work, so we need to set K to a larger value than N.

But how much larger should K be? That's the real question we have to answer. If we have that number, and that number is relatively small (and ideally upper-bounded by a de facto constant), then our problem is solved, and our retrieval will be efficient and correct.

The algorithm​

This is the solution we came up with:

  1. Pre-compute the number of embeddings per template.
  2. Pre-compute a max-heap where each node in the heap represents a template, and the value of the node is how many embeddings are associated with that template.
  3. Find the N largest nodes in the heap, representing the N templates with the most embeddings.
  4. Set K to the sum of the values of those N nodes—that's it.

Understanding the algorithm​

In the case above, if N=2 (number of templates to retrieve), setting K=13 (number of vectors to retrieve) would work.

Why 13? Because it guarantees that we retrieve enough embeddings to cover the templates with the most embeddings, while still being efficient.

Here's the analysis.

Suppose that the top 2 templates are indeed the ones with the most embeddings, and that they have 7 and 6 embeddings, respectively. In this case, we will retrieve all of their embeddings, which is 13 in total. So this case works.

But if the top 2 templates are not the two with the most embeddings, we will still retrieve enough embeddings to cover the top 2 templates.

For example, if the top 2 templates have 5 and 4 embeddings, respectively, we will retrieve 12>=9 embeddings, which is still more than enough to cover the top 2 templates.

As long as we retrieve enough embeddings to cover the worst case, which is when the top N templates have the most embeddings, we are guaranteed to retrieve the top N templates.

And this is how we achieve efficiency. We use KNN (literally, to retrieve the K nearest neighbors) to retrieve the top K embeddings, and, using our max-heap, we ensure that K is large enough to cover the top N templates with the most embeddings.

We then extract the top N templates from the fairly small set of K retrieved embeddings, which is efficient and correct.

Finally, here's the relevant implementation of finding K in Python:

import heapq

K = sum(heapq.nlargest(N, [count_vectors_per_utterance(u) for u in stored_utterances]))
github Get in touch with us