What you'll learn
Quick Answer
Prompt engineering is writing the input to an LLM so its output is consistent enough to use in code. The patterns that matter: put durable instructions in the system message, show two or three worked examples instead of describing the format, state the output shape explicitly, and give the model a way to say it does not know. Treat the prompt as code - version it, test it against real inputs, and change one thing at a time.
System, user, and assistant messages
Most chat APIs take a list of messages, each tagged with a role. The system message holds durable instructions - tone, output format, hard rules. User messages hold the actual request. Assistant messages hold the model's past replies, and in few-shot prompting, example replies you write yourself. Some APIs, such as Anthropic's, take the system prompt as a separate top-level field rather than an entry in the list, but the split is the same idea.
Keep per-request content - the document to summarize, the question - in user messages, and keep the stable rules in the system message. This matters for three reasons. Providers weight the system message more heavily, so rules there are followed more reliably. A stable system prefix is what lets prompt caching kick in and cut cost on repeated calls. And separating trusted instructions from untrusted user text is the first line of defence against prompt injection - if you concatenate a user's message into your instruction block, they can overwrite your rules.
{
"model": "...",
"messages": [
{ "role": "system",
"content": "You label support tickets. Reply with exactly one word: bug, billing, or other." },
{ "role": "user", "content": "I was charged twice this month." }
]
}
Few-shot examples beat descriptions
Instead of describing the output format in prose, show it. Add two or three example turns - a user message with a sample input, then an assistant message with the exact output you want - before the real request. The model copies the pattern it sees, and a concrete example pins down details that prose leaves fuzzy: key order, casing, whether numbers are quoted, what an empty result looks like.
{ "role": "user", "content": "Review: The screen cracked in a week." }
{ "role": "assistant", "content": "{\"sentiment\": \"negative\", \"topic\": \"hardware\"}" }
{ "role": "user", "content": "Review: Setup took two minutes, works great." }
{ "role": "assistant", "content": "{\"sentiment\": \"positive\", \"topic\": \"setup\"}" }
{ "role": "user", "content": "Review: Support never replied to my email." }The gotcha: the model copies your examples exactly, including mistakes. A trailing space, inconsistent capitalization, or a different key order in one example gets reproduced in the output. Keep examples byte-clean and identical in shape. And make them cover the ambiguous cases you actually care about - if every example is a clear-cut positive or negative, the model has no guide for the sarcastic review or the one that mentions two topics.
Make the output shape non-negotiable
If you parse the response in code, you cannot afford "Sure! Here is the JSON:" in front of it. State the rule in the system message - "Reply with only a JSON object, no prose, no code fences" - and give the schema. Better, use the provider's structured-output or JSON mode if it has one: that constrains decoding so the response is valid JSON against your schema by construction, not by request.
Even then, parse defensively. Wrap it in try/catch and log the raw text on failure, because model updates and unusual inputs still produce the occasional surprise. A concrete before and after: "Summarize this ticket" returns three sentences of varying length and tone; "Summarize this ticket in one sentence, maximum 20 words, no customer names" returns something you can drop straight into a table cell. Every detail you leave unspecified is a detail the model will vary between calls, and variance is what breaks downstream code.
Give the model an escape hatch
Models are trained to be helpful, which at the edges means guessing. Ask a question the context does not answer and the default behaviour is a plausible-sounding invention delivered with full confidence. Two fixes.
- Tell it explicitly what to do when it cannot answer: "If the answer is not in the text above, reply with exactly: NOT_FOUND."
- Put the source material in the prompt and restrict the model to it: "Use only the information between the --- markers. Do not use outside knowledge."
This does not eliminate hallucination, but it converts many silent wrong answers into an honest NOT_FOUND your code can branch on. Pair it with asking for evidence: "Support your answer with a direct quote from the source." A model that cannot produce a supporting quote is signalling that the answer is not really there - which you can check programmatically by confirming the quote appears in the source text.
Structure a long prompt
Once a prompt grows past a few lines, order and delimiters matter. A layout that holds up: role and rules first, then the reference material inside a clear delimiter, then the task, then the output format, then examples. Wrap pasted content in fenced markers or XML-style tags (<document>...</document>) so the model can tell your instructions from the data it is meant to act on.
For long reference material, repeat the actual instruction after it as well as before - the model attends most to the start and end of a long context, and an instruction buried only at the top of a 5,000-word block often gets missed. Cut the padding that does nothing: "please", "I would really appreciate it", "you are the world's best expert". It adds tokens and does not change output quality. And check for contradictions - "be concise" three lines above "explain your reasoning in detail" produces something that satisfies neither.
